Multi-stage Dockerfile.graalpy on ghcr.io/graalvm/graalpy-community:25 that builds the granian and rloop PyO3 extensions from source against GraalPy (no GraalPy wheels exist), installs the pure-Python deps, and serves the unchanged RSGI app on the rloop event loop. Adds docker-compose.graalpy.yml and a CI job publishing the image as the *-graalpy tags on release/*.
pyfconfig
A clone of ifconfig.me built on the
kaya framework
(kaya-core + kaya-rsgi), served by Granian
over the RSGI protocol.
Stack
- 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.
A second image runs the exact same app on GraalPy
(GraalVM Community Edition 25) instead of CPython — see
Dockerfile.graalpy and the
GraalPy image section. Because GraalPy is not ABI-compatible
with CPython wheels, the compiled granian and rloop extensions are built
from source against GraalPy during the image build.
Endpoints
| Method | Path | Response |
|---|---|---|
| GET | / |
HTML page for browsers (Accept: text/html), plain-text IP otherwise |
| GET | /ip |
Client IP address |
| GET | /ua |
User-Agent header |
| GET | /lang |
Accept-Language header |
| GET | /encoding |
Accept-Encoding header |
| GET | /mime |
Accept header |
| GET | /charset |
Accept-Charset header |
| GET | /forwarded |
X-Forwarded-For header |
| GET | /all |
All connection fields as key: value lines (empty fields included; remote_host shown as unavailable) |
| GET | /all.json |
Same fields as a JSON object, empty fields omitted |
| GET | /api/health |
Liveness probe ({"status":"ok"}) |
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); 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:
$ curl http://localhost:8080/
203.0.113.7
$ curl http://localhost:8080/all.json
{"ip_addr":"203.0.113.7","user_agent":"curl/8.21.0","port":"51342","method":"GET","mime":"*/*"}
Running
With Docker Compose (recommended)
docker compose up -d --build
# App published at http://localhost:8080
GraalPy image
An equivalent image based on ghcr.io/graalvm/graalpy-community:25
(GraalPy 25.0.2 / Python 3.12.8) is available via a separate compose file:
docker compose -f docker-compose.graalpy.yml up -d --build
# App published at http://localhost:8080
This image is published to the registry as the *-graalpy tags
(e.g. latest-graalpy, 0.1.0-graalpy).
On the host
cp .env.example .env # optional; all settings have defaults
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
The --loop rloop flag (or GRANIAN_LOOP=rloop, already set in the
Docker image) makes Granian run the app on the
rloop Rust event loop instead of the
stdlib asyncio loop.
Configuration
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).
Tests
. .venv/bin/activate
python -m unittest discover -s tests -t .
The tests drive the app in-process through kaya's ASGI interface using
httpx.ASGITransport; no external services are needed.
Project layout
src/pyfconfig/
├── app.py # KayaApp assembly; imports route modules
├── config.py # env-driven Settings dataclass
├── page.py # HTML landing page builder (kaya has no templating)
└── routes/
├── health.py # GET /api/health
├── root.py # GET / with content negotiation
└── connection.py # /ip /ua /lang /encoding /mime /charset /forwarded /all /all.json
tests/
└── test_routes.py # unittest + httpx.ASGITransport
Route modules import the module-level app from pyfconfig.app and
register handlers with @app.GET(...) decorators at import time —
pyfconfig.app imports them last to complete the wiring.
Deployment
Two images are built from this repository:
Dockerfile— multi-stage alpine build on CPython, producing a minimal, non-root runtime image.Dockerfile.graalpy— multi-stage Oracle Linux build on GraalPy 25, where the compiledgranianandrloopextensions are compiled from source against GraalPy (the Rust toolchain stays in the builder stage).
Pushing a release/* tag triggers the Gitea Actions workflow in
.gitea/workflows/build.yaml, which builds and publishes both images to
gitea.woggioni.net/woggioni/pyfconfig: the CPython image as latest /
<version>, and the GraalPy image as latest-graalpy / <version>-graalpy.