Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c6a5980ec
|
||
|
|
74c7400957
|
||
|
|
47d2280970
|
||
|
|
d51f380a3f
|
@@ -1,3 +1,8 @@
|
||||
# Name used in the HTML page title/heading and the curl examples
|
||||
# (e.g. "ifconfig.me" when deployed under that domain).
|
||||
SITE_NAME=pyfconfig
|
||||
|
||||
# Comma-separated CIDRs/IPs of trusted reverse proxies. Forwarded /
|
||||
# X-Forwarded-* headers are honored only when the direct peer belongs to
|
||||
# one of these; leave empty when the app is directly exposed.
|
||||
# TRUSTED_PROXY_CIDRS=127.0.0.1,10.0.0.0/8
|
||||
|
||||
@@ -32,7 +32,9 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: true
|
||||
pull: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -9,6 +9,7 @@ over the RSGI protocol.
|
||||
|
||||
- **kaya-core** — routing and HTTP request/response handling
|
||||
- **kaya-rsgi** — Granian (RSGI) adapter
|
||||
- **kaya-forwarded** — trusted-proxy handling of `Forwarded` / `X-Forwarded-*` headers
|
||||
- **granian** — application server
|
||||
- **rloop** — Rust event loop used by Granian instead of the stdlib asyncio loop
|
||||
- **httpx + pwo** — test client over kaya's ASGI transport
|
||||
@@ -36,8 +37,12 @@ The `/all` field order mirrors the reference site: `ip_addr`,
|
||||
`keep_alive`, `method`, `encoding`, `mime`, `charset`, `via`, `forwarded`.
|
||||
|
||||
The reported client IP/port honor the `Forwarded`, `X-Forwarded-For`,
|
||||
`X-Forwarded-Host` and `X-Forwarded-Port` proxy headers (kaya ≥ 0.0.2);
|
||||
without them the socket peer address is used.
|
||||
`X-Forwarded-Host` and `X-Forwarded-Port` proxy headers **only when the
|
||||
direct peer belongs to one of the `TRUSTED_PROXY_CIDRS`** (see
|
||||
[Configuration](#configuration)); the header chain is walked right-to-left
|
||||
skipping trusted proxies, so spoofed entries prepended by the client are
|
||||
never selected. Without trusted proxies configured, or when the peer is
|
||||
untrusted, the socket peer address is used.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -80,6 +85,7 @@ Environment variables (see `.env.example`):
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SITE_NAME` | `pyfconfig` | Public name used in the HTML page title and the curl examples (set to your domain, e.g. `ifconfig.example.com`) |
|
||||
| `TRUSTED_PROXY_CIDRS` | *(empty)* | Comma-separated CIDRs/IPs of trusted reverse proxies (e.g. `127.0.0.1,10.0.0.0/8`). Forwarded headers are honored only from these peers; empty means no proxy is trusted |
|
||||
|
||||
The bind address is configured through Granian itself (`GRANIAN_HOST` /
|
||||
`GRANIAN_PORT` env vars or `--host` / `--port` CLI flags).
|
||||
|
||||
@@ -7,5 +7,9 @@ services:
|
||||
# Public name shown in the HTML page and the curl examples; set to the
|
||||
# deployment domain (e.g. "ifconfig.example.com").
|
||||
SITE_NAME: ${SITE_NAME:-pyfconfig}
|
||||
# Comma-separated CIDRs of trusted reverse proxies (e.g.
|
||||
# "172.16.0.0/12"); required for correct client IPs when deployed
|
||||
# behind a reverse proxy. Empty means no proxy is trusted.
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
|
||||
+3
-2
@@ -9,8 +9,9 @@ description = "A clone of https://ifconfig.me/ built on the kaya framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"kaya-core>=0.0.2",
|
||||
"kaya-rsgi>=0.0.2",
|
||||
"kaya-core>=0.0.3",
|
||||
"kaya-rsgi>=0.0.3",
|
||||
"kaya-forwarded>=0.0.3",
|
||||
"granian>=2.0",
|
||||
"httpx",
|
||||
"pwo",
|
||||
|
||||
+5
-2
@@ -29,11 +29,14 @@ idna==3.19
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
kaya-core==0.0.2
|
||||
kaya-core==0.0.3
|
||||
# via
|
||||
# kaya-forwarded
|
||||
# kaya-rsgi
|
||||
# pyfconfig (pyproject.toml)
|
||||
kaya-rsgi==0.0.2
|
||||
kaya-forwarded==0.0.3
|
||||
# via pyfconfig (pyproject.toml)
|
||||
kaya-rsgi==0.0.3
|
||||
# via pyfconfig (pyproject.toml)
|
||||
pwo==0.1.2
|
||||
# via
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from kaya.core import KayaApp
|
||||
from kaya.forwarded import ForwardedHeadersMixin
|
||||
|
||||
app = KayaApp()
|
||||
from .config import settings
|
||||
|
||||
# Honor Forwarded / X-Forwarded-* headers, but only when the direct peer is a
|
||||
# trusted proxy (see TRUSTED_PROXY_CIDRS). With no trusted CIDRs configured
|
||||
# the mixin is a no-op pass-through.
|
||||
app = KayaApp(mixins=[
|
||||
ForwardedHeadersMixin(trusted_proxies=settings.trusted_proxy_cidrs),
|
||||
])
|
||||
|
||||
# Register routes by importing modules. Order does not matter; each module
|
||||
# pulls ``app`` from here and decorates its handlers at import time.
|
||||
|
||||
+20
-2
@@ -6,8 +6,9 @@ dataclass. No pydantic-settings, no settings module.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
from ipaddress import ip_network
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def _env(name: str, default: Optional[str] = None) -> str:
|
||||
@@ -19,9 +20,22 @@ def _env(name: str, default: Optional[str] = None) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _cidrs(name: str) -> Tuple[str, ...]:
|
||||
"""Parse a comma-separated list of CIDRs/IPs, validating each entry."""
|
||||
raw = os.environ.get(name) or ""
|
||||
entries = tuple(entry.strip() for entry in raw.split(",") if entry.strip())
|
||||
for entry in entries:
|
||||
try:
|
||||
ip_network(entry, strict=False)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Invalid CIDR in {name}: {entry!r}") from exc
|
||||
return entries
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
site_name: str
|
||||
trusted_proxy_cidrs: Tuple[str, ...] = field(default=())
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
@@ -29,6 +43,10 @@ class Settings:
|
||||
# Public name of the deployment, used in the HTML page title
|
||||
# and in the command-line examples (e.g. "ifconfig.me").
|
||||
site_name=_env("SITE_NAME", "pyfconfig"),
|
||||
# Comma-separated CIDRs/IPs of trusted reverse proxies; forwarded
|
||||
# headers are honored only when the socket peer belongs to one of
|
||||
# them. Empty (the default) means no proxy is trusted.
|
||||
trusted_proxy_cidrs=_cidrs("TRUSTED_PROXY_CIDRS"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+71
-23
@@ -17,23 +17,68 @@ _PAGE = """<!DOCTYPE html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>What Is My IP Address? - {site}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 2rem auto; max-width: 52rem; padding: 0 1rem; color: #222; }}
|
||||
h1 {{ font-size: 1.5rem; }}
|
||||
h2 {{ font-size: 1.15rem; margin-top: 2rem; border-bottom: 1px solid #ddd;
|
||||
padding-bottom: .3rem; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
td, th {{ border: 1px solid #ddd; padding: .4rem .6rem; text-align: left;
|
||||
vertical-align: top; }}
|
||||
td:first-child {{ font-weight: 600; white-space: nowrap; width: 12rem; }}
|
||||
code, pre {{ background: #f5f5f5; }}
|
||||
pre {{ padding: .8rem; overflow-x: auto; border: 1px solid #e2e2e2; }}
|
||||
footer {{ margin-top: 3rem; color: #777; font-size: .85rem; }}
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
border: 1px solid black;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
margin: 2rem auto;
|
||||
max-width: 52rem;
|
||||
padding: 0 1rem;
|
||||
color: #333;
|
||||
}}
|
||||
h1 a {{
|
||||
font-size: 20px;
|
||||
font-weight: 200;
|
||||
margin: 10px;
|
||||
letter-spacing: 1px;
|
||||
text-decoration: none;
|
||||
color: #555;
|
||||
}}
|
||||
h2 {{
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
font-size: 18px;
|
||||
margin: 0 5px;
|
||||
padding-left: 5px;
|
||||
font-weight: 200;
|
||||
}}
|
||||
div.cli-wrap {{
|
||||
padding-top: 20px;
|
||||
}}
|
||||
table {{
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
line-height: 20px;
|
||||
table-layout: fixed;
|
||||
}}
|
||||
table tr td, table tr th {{
|
||||
border: 1px solid black;
|
||||
padding: .4rem .6rem;
|
||||
text-align: left;
|
||||
vertical-align: center;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}}
|
||||
td:first-child {{ white-space: nowrap; width: 12rem; }}
|
||||
code, pre {{ background: #f5f5f5; margin: 0px; }}
|
||||
pre {{ padding: .8rem; overflow-x: auto; border: 1px solid #e2e2e2; overflow-x: hidden; }}
|
||||
footer {{
|
||||
background: #777;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
color: #ddd;
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
border-top: none;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>What Is My IP Address? - {site}</h1>
|
||||
<h1><a>What Is My IP Address? - {site}</a></h1>
|
||||
|
||||
<div>
|
||||
<h2>Your Connection</h2>
|
||||
<table>
|
||||
<tr><td>IP Address</td><td>{ip_addr}</td></tr>
|
||||
@@ -46,34 +91,36 @@ _PAGE = """<!DOCTYPE html>
|
||||
<tr><td>Charset</td><td>{charset}</td></tr>
|
||||
<tr><td>X-Forwarded-For</td><td>{forwarded}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="cli-wrap">
|
||||
<h2>Command Line Interface</h2>
|
||||
<pre>$ curl {site}
|
||||
⇒ {ip_addr}
|
||||
{ip_addr}
|
||||
|
||||
$ curl {site}/ip
|
||||
⇒ {ip_addr}
|
||||
{ip_addr}
|
||||
|
||||
$ curl {site}/ua
|
||||
⇒ {user_agent}
|
||||
{user_agent}
|
||||
|
||||
$ curl {site}/lang
|
||||
⇒ {language}
|
||||
{language}
|
||||
|
||||
$ curl {site}/encoding
|
||||
⇒ {encoding}
|
||||
{encoding}
|
||||
|
||||
$ curl {site}/mime
|
||||
⇒ {mime}
|
||||
{mime}
|
||||
|
||||
$ curl {site}/charset
|
||||
⇒ {charset}
|
||||
{charset}
|
||||
|
||||
$ curl {site}/forwarded
|
||||
⇒ {forwarded}
|
||||
{forwarded}
|
||||
|
||||
$ curl {site}/all
|
||||
⇒ ip_addr: {ip_addr}
|
||||
ip_addr: {ip_addr}
|
||||
remote_host: {remote_host}
|
||||
user_agent: {user_agent}
|
||||
port: {port}
|
||||
@@ -89,7 +136,8 @@ $ curl {site}/all
|
||||
forwarded: {forwarded}
|
||||
|
||||
$ curl {site}/all.json
|
||||
⇒ {all_json}</pre>
|
||||
{all_json}</pre>
|
||||
</div>
|
||||
|
||||
<footer>© {year} {site}</footer>
|
||||
</body>
|
||||
|
||||
+10
-3
@@ -1,6 +1,13 @@
|
||||
"""Test package init.
|
||||
|
||||
pyfconfig's settings all have safe defaults, so no environment overrides
|
||||
are required before importing :mod:`pyfconfig.app`; this file exists so
|
||||
``python -m unittest discover -s tests -t .`` treats tests as a package.
|
||||
``TRUSTED_PROXY_CIDRS`` must be set before :mod:`pyfconfig.config` is first
|
||||
imported because settings are read from the environment at import time.
|
||||
The ASGI transport used by the tests presents ``127.0.0.1`` as the socket
|
||||
peer, so it is trusted here together with ``10.0.0.0/8`` to exercise the
|
||||
right-to-left trusted-proxy chain walk.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("TRUSTED_PROXY_CIDRS", "127.0.0.1,10.0.0.0/8")
|
||||
|
||||
+29
-3
@@ -12,9 +12,10 @@ from pyfconfig.app import app
|
||||
# httpx's ASGITransport populates the scope with this client tuple.
|
||||
CLIENT_IP = "127.0.0.1"
|
||||
CLIENT_PORT = "123"
|
||||
# First entry of the X-Forwarded-For header in ALL_HEADERS; since kaya 0.0.2
|
||||
# ctx.client honors forwarded headers, ip_addr resolves to this instead of
|
||||
# the socket peer address.
|
||||
# First entry of the X-Forwarded-For header in ALL_HEADERS. With
|
||||
# kaya-forwarded (see tests/__init__.py for the trusted CIDRs), the chain is
|
||||
# walked right-to-left skipping trusted proxies: 10.0.0.1 is trusted, so
|
||||
# ip_addr resolves to this instead of the socket peer address.
|
||||
FORWARDED_IP = "203.0.113.7"
|
||||
|
||||
ALL_HEADERS = {
|
||||
@@ -58,6 +59,31 @@ class RoutesTest(unittest.TestCase):
|
||||
r = await client.get("/ip", headers={"X-Forwarded-For": "203.0.113.7"})
|
||||
self.assertEqual("203.0.113.7", r.text.strip())
|
||||
|
||||
@async_test
|
||||
async def test_ip_ignores_x_forwarded_for_from_untrusted_peer(self) -> None:
|
||||
# The socket peer is not in TRUSTED_PROXY_CIDRS, so proxy headers
|
||||
# are ignored and the peer address itself is reported.
|
||||
transport = ASGITransport(app=app, client=("192.0.2.10", 5555))
|
||||
async with AsyncClient(transport=transport, base_url="http://192.0.2.10") as client:
|
||||
r = await client.get("/ip", headers={"X-Forwarded-For": "203.0.113.7"})
|
||||
self.assertEqual("192.0.2.10", r.text.strip())
|
||||
|
||||
@async_test
|
||||
async def test_ip_all_trusted_chain_uses_leftmost_entry(self) -> None:
|
||||
# 10.1.2.3 is inside the trusted 10.0.0.0/8, so the whole chain is
|
||||
# trusted and the leftmost entry is the original client.
|
||||
async with self.client() as client:
|
||||
r = await client.get("/ip", headers={"X-Forwarded-For": "10.1.2.3"})
|
||||
self.assertEqual("10.1.2.3", r.text.strip())
|
||||
|
||||
@async_test
|
||||
async def test_forwarded_header_with_port(self) -> None:
|
||||
async with self.client() as client:
|
||||
r = await client.get("/all.json", headers={"Forwarded": "for=203.0.113.7:4455"})
|
||||
data = json.loads(r.text)
|
||||
self.assertEqual("203.0.113.7", data["ip_addr"])
|
||||
self.assertEqual("4455", data["port"])
|
||||
|
||||
@async_test
|
||||
async def test_ua(self) -> None:
|
||||
async with self.client() as client:
|
||||
|
||||
Reference in New Issue
Block a user