Initial commit: ifconfig.me clone on the kaya framework
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
# Keep the build context lean: exclude the local venv, caches, tests,
|
||||||
|
# and anything not needed to build the pyfconfig wheel.
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
tests/
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
opencode.json
|
||||||
|
docker-compose.yml
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# App server
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8000
|
||||||
|
|
||||||
|
# Name used in the HTML page title/heading and the curl examples
|
||||||
|
# (e.g. "ifconfig.me" when deployed under that domain).
|
||||||
|
SITE_NAME=pyfconfig
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'release/*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_push_docker_image:
|
||||||
|
name: "Build and push docker image"
|
||||||
|
runs-on: hostinger
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Login to Gitea container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.woggioni.net
|
||||||
|
username: woggioni-opencode-agent
|
||||||
|
password: ${{ secrets.PUBLISHER_TOKEN }}
|
||||||
|
- name: Extract metadata for image tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: gitea.woggioni.net/woggioni-opencode-agent/pyfconfig
|
||||||
|
flavor: latest=false
|
||||||
|
tags: |
|
||||||
|
type=match,pattern=release/(.*),group=1
|
||||||
|
type=raw,value=latest
|
||||||
|
- name: Build and push docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
pull: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Multi-stage production build for pyfconfig on the kaya framework.
|
||||||
|
# Base: alpine:3.24 (python3 = 3.14). Deps come from the kaya Gitea registry
|
||||||
|
# (primary) with PyPI as fallback. granian ships musllinux wheels, so no
|
||||||
|
# compiler is strictly required; build-base + python3-dev are kept in the
|
||||||
|
# builder only as a safety net and are discarded in the runtime image.
|
||||||
|
#
|
||||||
|
# apk and pip both use BuildKit cache mounts (type=cache): the package
|
||||||
|
# caches persist in the builder's cache across builds instead of being
|
||||||
|
# re-downloaded, and never land in the image layers. Requires BuildKit
|
||||||
|
# (default for `docker build` / `docker buildx` on modern daemons).
|
||||||
|
|
||||||
|
# --- Builder ---------------------------------------------------------------
|
||||||
|
FROM alpine:3.24 AS builder
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
|
apk add python3 py3-pip build-base python3-dev
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md requirements.txt ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
python3 -m venv /opt/venv \
|
||||||
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
|
&& /opt/venv/bin/pip install -r requirements.txt .
|
||||||
|
|
||||||
|
# --- Runtime ---------------------------------------------------------------
|
||||||
|
FROM alpine:3.24
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
|
apk add python3 ca-certificates tzdata \
|
||||||
|
&& addgroup -S app && adduser -S -G app app
|
||||||
|
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
GRANIAN_HOST=0.0.0.0 \
|
||||||
|
GRANIAN_PORT=8080 \
|
||||||
|
GRANIAN_INTERFACE=rsgi
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
|
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["granian", "pyfconfig.app:app"]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# pyfconfig
|
||||||
|
|
||||||
|
A clone of [ifconfig.me](https://ifconfig.me/) built on the
|
||||||
|
[kaya](https://gitea.woggioni.net/woggioni/kaya) framework
|
||||||
|
(`kaya-core` + `kaya-rsgi`), served by [Granian](https://github.com/emmett-framework/granian)
|
||||||
|
over the RSGI protocol.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **kaya-core** — routing and HTTP request/response handling
|
||||||
|
- **kaya-rsgi** — Granian (RSGI) adapter
|
||||||
|
- **granian** — application server
|
||||||
|
- **httpx + pwo** — test client over kaya's ASGI transport
|
||||||
|
|
||||||
|
No database, sessions, or authentication — the app is stateless.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ 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)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
# App published at http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### On the host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 --host 0.0.0.0 --port 8000 pyfconfig.app:app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Environment variables (see `.env.example`):
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `APP_HOST` | `0.0.0.0` | Bind host (informational; granian is configured via `GRANIAN_*`) |
|
||||||
|
| `APP_PORT` | `8000` | Bind port (informational) |
|
||||||
|
| `SITE_NAME` | `pyfconfig` | Public name used in the HTML page title and the curl examples (set to your domain, e.g. `ifconfig.example.com`) |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. .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
|
||||||
|
|
||||||
|
The `Dockerfile` is a multi-stage alpine build producing a minimal,
|
||||||
|
non-root runtime image; pushing a `release/*` tag triggers the Gitea
|
||||||
|
Actions workflow in `.gitea/workflows/build.yaml`, which builds and
|
||||||
|
publishes the image to `gitea.woggioni.net/woggioni-opencode-agent/pyfconfig`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
pyfconfig:
|
||||||
|
build: .
|
||||||
|
image: pyfconfig:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# 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}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"references": {
|
||||||
|
"kaya": {
|
||||||
|
"path": "../kaya",
|
||||||
|
"description": "Use for Kaya project code and resources"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pyfconfig"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A clone of https://ifconfig.me/ built on the kaya framework"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"kaya-core",
|
||||||
|
"kaya-rsgi",
|
||||||
|
"granian>=2.0",
|
||||||
|
"httpx",
|
||||||
|
"pwo",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"mypy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = false
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
plugins = []
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.14
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --no-index --output-file=requirements.txt pyproject.toml
|
||||||
|
#
|
||||||
|
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
|
||||||
|
--extra-index-url https://pypi.org/simple
|
||||||
|
|
||||||
|
anyio==4.15.0
|
||||||
|
# via httpx
|
||||||
|
certifi==2026.7.22
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
click==8.5.0
|
||||||
|
# via granian
|
||||||
|
granian==2.8.2
|
||||||
|
# via
|
||||||
|
# kaya-rsgi
|
||||||
|
# pyfconfig (pyproject.toml)
|
||||||
|
h11==0.16.0
|
||||||
|
# via httpcore
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httpx==0.28.1
|
||||||
|
# via pyfconfig (pyproject.toml)
|
||||||
|
idna==3.19
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
kaya-core==0.0.1
|
||||||
|
# via
|
||||||
|
# kaya-rsgi
|
||||||
|
# pyfconfig (pyproject.toml)
|
||||||
|
kaya-rsgi==0.0.1
|
||||||
|
# via pyfconfig (pyproject.toml)
|
||||||
|
pwo==0.1.2
|
||||||
|
# via
|
||||||
|
# kaya-core
|
||||||
|
# kaya-rsgi
|
||||||
|
# pyfconfig (pyproject.toml)
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# kaya-core
|
||||||
|
# pwo
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Application entry point."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kaya.core import KayaApp
|
||||||
|
|
||||||
|
app = KayaApp()
|
||||||
|
|
||||||
|
# Register routes by importing modules. Order does not matter; each module
|
||||||
|
# pulls ``app`` from here and decorates its handlers at import time.
|
||||||
|
from .routes import connection, health, root # noqa: E402,F401
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Environment-driven configuration for the pyfconfig application.
|
||||||
|
|
||||||
|
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
|
||||||
|
dataclass. No pydantic-settings, no settings module.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, default: Optional[str] = None) -> str:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
if default is None:
|
||||||
|
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||||
|
return default
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
app_host: str
|
||||||
|
app_port: int
|
||||||
|
site_name: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_env() -> "Settings":
|
||||||
|
return Settings(
|
||||||
|
app_host=_env("APP_HOST", "0.0.0.0"),
|
||||||
|
app_port=int(_env("APP_PORT", "8000")),
|
||||||
|
# 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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings: Settings = Settings.from_env()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""HTML landing page for browser visitors.
|
||||||
|
|
||||||
|
Kaya has no templating engine, so the page is assembled as a plain
|
||||||
|
string; every dynamic value is HTML-escaped.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from html import escape
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
_PAGE = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<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; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>What Is My IP Address? - {site}</h1>
|
||||||
|
|
||||||
|
<h2>Your Connection</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>IP Address</td><td>{ip_addr}</td></tr>
|
||||||
|
<tr><td>User Agent</td><td>{user_agent}</td></tr>
|
||||||
|
<tr><td>Language</td><td>{language}</td></tr>
|
||||||
|
<tr><td>Referer</td><td>{referer}</td></tr>
|
||||||
|
<tr><td>Method</td><td>{method}</td></tr>
|
||||||
|
<tr><td>Encoding</td><td>{encoding}</td></tr>
|
||||||
|
<tr><td>MIME Type</td><td>{mime}</td></tr>
|
||||||
|
<tr><td>Charset</td><td>{charset}</td></tr>
|
||||||
|
<tr><td>X-Forwarded-For</td><td>{forwarded}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Command Line Interface</h2>
|
||||||
|
<pre>$ curl {site}
|
||||||
|
⇒ {ip_addr}
|
||||||
|
|
||||||
|
$ curl {site}/ip
|
||||||
|
⇒ {ip_addr}
|
||||||
|
|
||||||
|
$ curl {site}/ua
|
||||||
|
⇒ {user_agent}
|
||||||
|
|
||||||
|
$ curl {site}/lang
|
||||||
|
⇒ {language}
|
||||||
|
|
||||||
|
$ curl {site}/encoding
|
||||||
|
⇒ {encoding}
|
||||||
|
|
||||||
|
$ curl {site}/mime
|
||||||
|
⇒ {mime}
|
||||||
|
|
||||||
|
$ curl {site}/charset
|
||||||
|
⇒ {charset}
|
||||||
|
|
||||||
|
$ curl {site}/forwarded
|
||||||
|
⇒ {forwarded}
|
||||||
|
|
||||||
|
$ curl {site}/all
|
||||||
|
⇒ ip_addr: {ip_addr}
|
||||||
|
remote_host: {remote_host}
|
||||||
|
user_agent: {user_agent}
|
||||||
|
port: {port}
|
||||||
|
language: {language}
|
||||||
|
referer: {referer}
|
||||||
|
connection: {connection}
|
||||||
|
keep_alive: {keep_alive}
|
||||||
|
method: {method}
|
||||||
|
encoding: {encoding}
|
||||||
|
mime: {mime}
|
||||||
|
charset: {charset}
|
||||||
|
via: {via}
|
||||||
|
forwarded: {forwarded}
|
||||||
|
|
||||||
|
$ curl {site}/all.json
|
||||||
|
⇒ {all_json}</pre>
|
||||||
|
|
||||||
|
<footer>© {year} {site}</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(data: Dict[str, str], site: str) -> str:
|
||||||
|
"""Render the landing page from the connection data mapping."""
|
||||||
|
escaped = {key: escape(value) for key, value in data.items()}
|
||||||
|
escaped["remote_host"] = escaped["remote_host"] or "unavailable"
|
||||||
|
all_json = json.dumps(
|
||||||
|
{key: value for key, value in data.items() if value},
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
return _PAGE.format(
|
||||||
|
site=escape(site),
|
||||||
|
year=datetime.now(timezone.utc).year,
|
||||||
|
all_json=escape(all_json),
|
||||||
|
**escaped,
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Connection introspection endpoints — the core of the ifconfig.me clone.
|
||||||
|
|
||||||
|
A single :func:`connection_data` helper collects every field from the
|
||||||
|
request context in the same order the reference site
|
||||||
|
(https://ifconfig.me/all) uses; the individual endpoints below are thin
|
||||||
|
views over that mapping.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
|
||||||
|
from ..app import app
|
||||||
|
|
||||||
|
_PLAIN = {"Content-Type": "text/plain; charset=utf-8"}
|
||||||
|
_JSON = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _header(ctx: HttpContext, name: str) -> str:
|
||||||
|
values = ctx.headers.get(name)
|
||||||
|
if not values:
|
||||||
|
return ""
|
||||||
|
return ", ".join(values)
|
||||||
|
|
||||||
|
|
||||||
|
def connection_data(ctx: HttpContext) -> Dict[str, str]:
|
||||||
|
"""Collect the connection fields, ordered like the reference site."""
|
||||||
|
client: Optional[Tuple[str, int]] = ctx.client
|
||||||
|
ip_addr = client[0] if client else ""
|
||||||
|
port = str(client[1]) if client else ""
|
||||||
|
return {
|
||||||
|
"ip_addr": ip_addr,
|
||||||
|
# Reverse DNS resolution is not performed; ``/all`` renders an
|
||||||
|
# empty value as "unavailable" and ``/all.json`` omits it.
|
||||||
|
"remote_host": "",
|
||||||
|
"user_agent": _header(ctx, "user-agent"),
|
||||||
|
"port": port,
|
||||||
|
"language": _header(ctx, "accept-language"),
|
||||||
|
"referer": _header(ctx, "referer"),
|
||||||
|
"connection": _header(ctx, "connection"),
|
||||||
|
"keep_alive": _header(ctx, "keep-alive"),
|
||||||
|
"method": ctx.method.value,
|
||||||
|
"encoding": _header(ctx, "accept-encoding"),
|
||||||
|
"mime": _header(ctx, "accept"),
|
||||||
|
"charset": _header(ctx, "accept-charset"),
|
||||||
|
"via": _header(ctx, "via"),
|
||||||
|
"forwarded": _header(ctx, "x-forwarded-for"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_field(ctx: HttpContext, field: str) -> None:
|
||||||
|
await ctx.send_str(200, connection_data(ctx)[field] + "\n", _PLAIN)
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/ip")
|
||||||
|
async def ip(ctx: HttpContext) -> None:
|
||||||
|
await _send_field(ctx, "ip_addr")
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/ua")
|
||||||
|
async def user_agent(ctx: HttpContext) -> None:
|
||||||
|
await _send_field(ctx, "user_agent")
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/lang")
|
||||||
|
async def language(ctx: HttpContext) -> None:
|
||||||
|
await _send_field(ctx, "language")
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/encoding")
|
||||||
|
async def encoding(ctx: HttpContext) -> None:
|
||||||
|
await _send_field(ctx, "encoding")
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/mime")
|
||||||
|
async def mime(ctx: HttpContext) -> None:
|
||||||
|
await _send_field(ctx, "mime")
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/charset")
|
||||||
|
async def charset(ctx: HttpContext) -> None:
|
||||||
|
await _send_field(ctx, "charset")
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/forwarded")
|
||||||
|
async def forwarded(ctx: HttpContext) -> None:
|
||||||
|
await _send_field(ctx, "forwarded")
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/all")
|
||||||
|
async def all_text(ctx: HttpContext) -> None:
|
||||||
|
data = connection_data(ctx)
|
||||||
|
lines = [
|
||||||
|
f"{key}: {value if value else ('unavailable' if key == 'remote_host' else '')}"
|
||||||
|
for key, value in data.items()
|
||||||
|
]
|
||||||
|
await ctx.send_str(200, "\n".join(lines) + "\n", _PLAIN)
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/all.json")
|
||||||
|
async def all_json(ctx: HttpContext) -> None:
|
||||||
|
data = {key: value for key, value in connection_data(ctx).items() if value}
|
||||||
|
await ctx.send_str(200, json.dumps(data, separators=(",", ":")) + "\n", _JSON)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Liveness probe."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
|
||||||
|
from ..app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/health")
|
||||||
|
async def health(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_bytes(
|
||||||
|
200,
|
||||||
|
b'{"status":"ok"}',
|
||||||
|
{"content-type": ("application/json",)},
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Landing page with content negotiation.
|
||||||
|
|
||||||
|
Browsers (``Accept: text/html``) get the HTML page; command-line clients
|
||||||
|
such as curl (``Accept: */*``) get the plain-text IP address, mirroring
|
||||||
|
the behaviour of https://ifconfig.me/.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
|
||||||
|
from ..app import app
|
||||||
|
from ..config import settings
|
||||||
|
from ..page import render_page
|
||||||
|
from .connection import connection_data
|
||||||
|
|
||||||
|
_HTML = {"Content-Type": "text/html; charset=utf-8"}
|
||||||
|
_PLAIN = {"Content-Type": "text/plain; charset=utf-8"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/")
|
||||||
|
async def index(ctx: HttpContext) -> None:
|
||||||
|
data = connection_data(ctx)
|
||||||
|
accept = ", ".join(ctx.headers.get("accept", ()))
|
||||||
|
if "text/html" in accept:
|
||||||
|
await ctx.send_str(200, render_page(data, settings.site_name), _HTML)
|
||||||
|
else:
|
||||||
|
await ctx.send_str(200, data["ip_addr"] + "\n", _PLAIN)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Route tests using kaya's ASGI transport via httpx."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from pwo import async_test
|
||||||
|
|
||||||
|
from pyfconfig.app import app
|
||||||
|
|
||||||
|
# httpx's ASGITransport populates the scope with this client tuple.
|
||||||
|
CLIENT_IP = "127.0.0.1"
|
||||||
|
CLIENT_PORT = "123"
|
||||||
|
|
||||||
|
ALL_HEADERS = {
|
||||||
|
"User-Agent": "test-agent/1.0",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Referer": "https://example.com/page",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Keep-Alive": "timeout=5",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Accept": "text/html,application/xhtml+xml",
|
||||||
|
"Accept-Charset": "utf-8",
|
||||||
|
"Via": "1.1 proxy",
|
||||||
|
"X-Forwarded-For": "203.0.113.7, 10.0.0.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RoutesTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.transport = ASGITransport(app=app)
|
||||||
|
|
||||||
|
def client(self) -> AsyncClient:
|
||||||
|
return AsyncClient(transport=self.transport, base_url="http://127.0.0.1")
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_health(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/api/health")
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertIn("ok", r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ip(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/ip")
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertEqual(CLIENT_IP, r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_ua(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/ua", headers={"User-Agent": "test-agent/1.0"})
|
||||||
|
self.assertEqual("test-agent/1.0", r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_lang(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/lang", headers={"Accept-Language": "en-US"})
|
||||||
|
self.assertEqual("en-US", r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_encoding(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/encoding", headers={"Accept-Encoding": "gzip"})
|
||||||
|
self.assertEqual("gzip", r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_mime(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/mime", headers={"Accept": "application/json"})
|
||||||
|
self.assertEqual("application/json", r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_charset(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/charset", headers={"Accept-Charset": "utf-8"})
|
||||||
|
self.assertEqual("utf-8", r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forwarded(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/forwarded", headers={"X-Forwarded-For": "203.0.113.7"})
|
||||||
|
self.assertEqual("203.0.113.7", r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_forwarded_absent_is_empty(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/forwarded")
|
||||||
|
self.assertEqual("", r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_all_text(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/all", headers=ALL_HEADERS)
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
lines = r.text.strip().splitlines()
|
||||||
|
keys = [line.split(":", 1)[0] for line in lines]
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"ip_addr", "remote_host", "user_agent", "port", "language",
|
||||||
|
"referer", "connection", "keep_alive", "method", "encoding",
|
||||||
|
"mime", "charset", "via", "forwarded",
|
||||||
|
],
|
||||||
|
keys,
|
||||||
|
)
|
||||||
|
values = dict(line.split(": ", 1) for line in lines)
|
||||||
|
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"])
|
||||||
|
self.assertEqual("en-US,en;q=0.9", values["language"])
|
||||||
|
self.assertEqual("https://example.com/page", values["referer"])
|
||||||
|
self.assertEqual("keep-alive", values["connection"])
|
||||||
|
self.assertEqual("timeout=5", values["keep_alive"])
|
||||||
|
self.assertEqual("GET", values["method"])
|
||||||
|
self.assertEqual("gzip, deflate", values["encoding"])
|
||||||
|
self.assertEqual("text/html,application/xhtml+xml", values["mime"])
|
||||||
|
self.assertEqual("utf-8", values["charset"])
|
||||||
|
self.assertEqual("1.1 proxy", values["via"])
|
||||||
|
self.assertEqual("203.0.113.7, 10.0.0.1", values["forwarded"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_all_json_omits_empty_fields(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/all.json", headers=ALL_HEADERS)
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
data = json.loads(r.text)
|
||||||
|
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"])
|
||||||
|
# Empty fields are omitted entirely.
|
||||||
|
data_minimal = json.loads(
|
||||||
|
(await client.get("/all.json")).text
|
||||||
|
)
|
||||||
|
self.assertNotIn("remote_host", data_minimal)
|
||||||
|
self.assertNotIn("language", data_minimal)
|
||||||
|
self.assertNotIn("forwarded", data_minimal)
|
||||||
|
self.assertEqual(CLIENT_IP, data_minimal["ip_addr"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_root_plain_text_for_cli(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
# httpx sends Accept: */* by default, like curl.
|
||||||
|
r = await client.get("/")
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertIn("text/plain", r.headers["content-type"])
|
||||||
|
self.assertEqual(CLIENT_IP, r.text.strip())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_root_html_for_browsers(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/", headers={"Accept": "text/html"})
|
||||||
|
self.assertEqual(200, r.status_code)
|
||||||
|
self.assertIn("text/html", r.headers["content-type"])
|
||||||
|
self.assertIn("What Is My IP Address?", r.text)
|
||||||
|
self.assertIn(CLIENT_IP, r.text)
|
||||||
|
self.assertIn("curl pyfconfig/all.json", r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_root_html_escapes_user_input(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get(
|
||||||
|
"/",
|
||||||
|
headers={
|
||||||
|
"Accept": "text/html",
|
||||||
|
"User-Agent": "<script>alert(1)</script>",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertNotIn("<script>alert(1)</script>", r.text)
|
||||||
|
self.assertIn("<script>", r.text)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_unknown_path_is_404(self) -> None:
|
||||||
|
async with self.client() as client:
|
||||||
|
r = await client.get("/nope")
|
||||||
|
self.assertEqual(404, r.status_code)
|
||||||
Reference in New Issue
Block a user