3 Commits
Author SHA1 Message Date
woggioni ff99f65907 changed image repository name
CI / Build and push docker image (push) Successful in 2m3s
2026-09-04 08:30:54 +08:00
woggioni-opencode-agent 48791d1cf5 Remove unused APP_HOST/APP_PORT settings 2026-09-04 00:21:57 +00:00
woggioni-opencode-agent 9523f357df Initial commit: ifconfig.me clone on the kaya framework 2026-09-03 13:13:05 +00:00
12 changed files with 38 additions and 190 deletions
-5
View File
@@ -1,8 +1,3 @@
# 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
+1 -3
View File
@@ -32,9 +32,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
platforms: |
linux/amd64
linux/arm64
platforms: linux/amd64
push: true
pull: true
tags: ${{ steps.meta.outputs.tags }}
+1 -2
View File
@@ -40,8 +40,7 @@ ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
GRANIAN_HOST=0.0.0.0 \
GRANIAN_PORT=8080 \
GRANIAN_INTERFACE=rsgi \
GRANIAN_LOOP=rloop
GRANIAN_INTERFACE=rsgi
USER app
+1 -17
View File
@@ -9,9 +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
No database, sessions, or authentication — the app is stateless.
@@ -36,14 +34,6 @@ The `/all` field order mirrors the reference site: `ip_addr`,
`remote_host`, `user_agent`, `port`, `language`, `referer`, `connection`,
`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 **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:
```console
@@ -70,14 +60,9 @@ python -m venv .venv && . .venv/bin/activate
pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple \
--extra-index-url https://pypi.org/simple \
-e .
granian --interface rsgi --loop rloop --host 0.0.0.0 --port 8000 pyfconfig.app:app
granian --interface rsgi --host 0.0.0.0 --port 8000 pyfconfig.app:app
```
The `--loop rloop` flag (or `GRANIAN_LOOP=rloop`, already set in the
Docker image) makes Granian run the app on the
[rloop](https://github.com/gi0baro/rloop) Rust event loop instead of the
stdlib asyncio loop.
### Configuration
Environment variables (see `.env.example`):
@@ -85,7 +70,6 @@ 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).
-4
View File
@@ -7,9 +7,5 @@ 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"
+2 -4
View File
@@ -9,13 +9,11 @@ description = "A clone of https://ifconfig.me/ built on the kaya framework"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kaya-core>=0.0.3",
"kaya-rsgi>=0.0.3",
"kaya-forwarded>=0.0.3",
"kaya-core",
"kaya-rsgi",
"granian>=2.0",
"httpx",
"pwo",
"rloop>=0.5.0",
]
[project.optional-dependencies]
+2 -7
View File
@@ -29,22 +29,17 @@ idna==3.19
# via
# anyio
# httpx
kaya-core==0.0.3
kaya-core==0.0.1
# via
# kaya-forwarded
# kaya-rsgi
# pyfconfig (pyproject.toml)
kaya-forwarded==0.0.3
# via pyfconfig (pyproject.toml)
kaya-rsgi==0.0.3
kaya-rsgi==0.0.1
# via pyfconfig (pyproject.toml)
pwo==0.1.2
# via
# kaya-core
# kaya-rsgi
# pyfconfig (pyproject.toml)
rloop==0.5.0
# via pyfconfig (pyproject.toml)
typing-extensions==4.16.0
# via
# anyio
+1 -9
View File
@@ -2,16 +2,8 @@
from __future__ import annotations
from kaya.core import KayaApp
from kaya.forwarded import ForwardedHeadersMixin
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),
])
app = KayaApp()
# Register routes by importing modules. Order does not matter; each module
# pulls ``app`` from here and decorates its handlers at import time.
+2 -20
View File
@@ -6,9 +6,8 @@ dataclass. No pydantic-settings, no settings module.
from __future__ import annotations
import os
from dataclasses import dataclass, field
from ipaddress import ip_network
from typing import Optional, Tuple
from dataclasses import dataclass
from typing import Optional
def _env(name: str, default: Optional[str] = None) -> str:
@@ -20,22 +19,9 @@ 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":
@@ -43,10 +29,6 @@ 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"),
)
+23 -71
View File
@@ -17,68 +17,23 @@ _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: 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;
}}
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; }}
</style>
</head>
<body>
<h1><a>What Is My IP Address? - {site}</a></h1>
<h1>What Is My IP Address? - {site}</h1>
<div>
<h2>Your Connection</h2>
<table>
<tr><td>IP Address</td><td>{ip_addr}</td></tr>
@@ -91,36 +46,34 @@ _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}
&#8658; {ip_addr}
$ curl {site}/ip
{ip_addr}
&#8658; {ip_addr}
$ curl {site}/ua
{user_agent}
&#8658; {user_agent}
$ curl {site}/lang
{language}
&#8658; {language}
$ curl {site}/encoding
{encoding}
&#8658; {encoding}
$ curl {site}/mime
{mime}
&#8658; {mime}
$ curl {site}/charset
{charset}
&#8658; {charset}
$ curl {site}/forwarded
{forwarded}
&#8658; {forwarded}
$ curl {site}/all
ip_addr: {ip_addr}
&#8658; ip_addr: {ip_addr}
remote_host: {remote_host}
user_agent: {user_agent}
port: {port}
@@ -136,8 +89,7 @@ ip_addr: {ip_addr}
forwarded: {forwarded}
$ curl {site}/all.json
{all_json}</pre>
</div>
&#8658; {all_json}</pre>
<footer>&copy; {year} {site}</footer>
</body>
+3 -10
View File
@@ -1,13 +1,6 @@
"""Test package init.
``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.
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.
"""
from __future__ import annotations
import os
os.environ.setdefault("TRUSTED_PROXY_CIDRS", "127.0.0.1,10.0.0.0/8")
+2 -38
View File
@@ -12,11 +12,6 @@ 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. 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 = {
"User-Agent": "test-agent/1.0",
@@ -53,37 +48,6 @@ class RoutesTest(unittest.TestCase):
self.assertEqual(200, r.status_code)
self.assertEqual(CLIENT_IP, r.text.strip())
@async_test
async def test_ip_honors_x_forwarded_for(self) -> None:
async with self.client() as client:
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:
@@ -142,7 +106,7 @@ class RoutesTest(unittest.TestCase):
keys,
)
values = dict(line.split(": ", 1) for line in lines)
self.assertEqual(FORWARDED_IP, values["ip_addr"])
self.assertEqual(CLIENT_IP, values["ip_addr"])
self.assertEqual("unavailable", values["remote_host"])
self.assertEqual("test-agent/1.0", values["user_agent"])
self.assertEqual(CLIENT_PORT, values["port"])
@@ -163,7 +127,7 @@ class RoutesTest(unittest.TestCase):
r = await client.get("/all.json", headers=ALL_HEADERS)
self.assertEqual(200, r.status_code)
data = json.loads(r.text)
self.assertEqual(FORWARDED_IP, data["ip_addr"])
self.assertEqual(CLIENT_IP, data["ip_addr"])
self.assertEqual("test-agent/1.0", data["user_agent"])
self.assertEqual(CLIENT_PORT, data["port"])
self.assertEqual("GET", data["method"])