Build KarlDNS self-hosted DynDNS service

This commit is contained in:
StasonJatham committed 2026-05-20 23:55:27 +02:00
1 parent 45bc38d950
commit ca92dc211a
65 files changed
+9200 -3570

No files matched your search

+24 -11
View File
@@ -1,18 +1,31 @@
# Required for a public KarlDNS instance.
DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password
DDNS_ADMIN_PATH=/admin
DDNS_PUBLIC_BASE_URL=https://karldns.de
DDNS_HOSTNAME_SUFFIX=karldns.de
DDNS_DATABASE=/data/ddns.sqlite3
DDNS_TRUSTED_HOSTS=karldns.de
DDNS_DNS_PROVIDER=cloudflare
DDNS_DNS_ZONES=karldns.de
# Required DNS backend. Use a least-privilege Cloudflare token for this zone only.
DDNS_REQUIRE_DNS_PROVIDER=1
DDNS_DNS_PROVIDER=cloudflare
DDNS_CLOUDFLARE_API_TOKEN=change-me-cloudflare-token
DDNS_CLOUDFLARE_ZONE_ID=change-me-cloudflare-zone-id
# Usually leave these defaults alone.
DDNS_DATABASE=/data/ddns.sqlite3
DDNS_TTL=60
DDNS_RATE_LIMIT_PER_MINUTE=60
DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
DDNS_MAX_REQUEST_BODY_BYTES=16384
DDNS_CLEANUP_CHALLENGE_HOURS=72
DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
DDNS_CLEANUP_INTERVAL_SECONDS=3600
DDNS_TRUSTED_PROXY_IPS=127.0.0.1,::1
# Optional hardening/tuning.
# Keep app access logs disabled unless request URLs are redacted; update links are bearer secrets.
# Run a single Uvicorn worker by default. Rate-limit counters and per-host update locks are process-local.
# DDNS_TRUSTED_HOSTS=karldns.de,www.karldns.de
# DDNS_DNS_ZONES=karldns.de
# DDNS_TRUSTED_PROXY_IPS=127.0.0.1,::1
# DDNS_RATE_LIMIT_PER_MINUTE=60
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
# DDNS_MAX_REQUEST_BODY_BYTES=16384
# DDNS_CLEANUP_CHALLENGE_HOURS=72
# DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
# DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320
# DDNS_CLEANUP_INTERVAL_SECONDS=3600
# DDNS_DASHBOARD_UNLOCK_SECONDS=86400
# DDNS_DASHBOARD_STAY_UNLOCKED_SECONDS=2592000
+2
View File
@@ -11,3 +11,5 @@ dist/
build/
*.egg-info/
tmp/
backend_audit_report.md
security_best_practices_report.md
+1 -1
View File
@@ -1,4 +1,4 @@
karldns.de {
encode zstd gzip
reverse_proxy ddns:8080
reverse_proxy karldns:8080
}
+6 -4
View File
@@ -1,13 +1,15 @@
FROM python:3.12-slim
ENV DDNS_DATABASE=/data/ddns.sqlite3
WORKDIR /app
COPY pyproject.toml README.md /app/
COPY router_dyndns /app/router_dyndns
RUN pip install --no-cache-dir .
RUN adduser --system --group --home /app routerpulse \
RUN adduser --system --group --home /app karldns \
&& mkdir -p /data \
&& chown -R routerpulse:routerpulse /app /data
USER routerpulse
&& chown -R karldns:karldns /app /data
USER karldns
EXPOSE 8080
CMD ["router-dyndns", "serve", "--host", "0.0.0.0", "--port", "8080"]
CMD ["karldns", "serve", "--host", "0.0.0.0", "--port", "8080"]
+124 -23
View File
@@ -69,8 +69,8 @@ That means users always have a current hostname for VPN, remote access, self-hos
## Quick Start
```bash
git clone https://github.com/StasonJatham/router-dyndns.git
cd router-dyndns
git clone https://github.com/StasonJatham/karldns.de.git
cd karldns.de
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
@@ -80,6 +80,7 @@ Run locally:
```bash
export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value'
export DDNS_ADMIN_PATH='/admin'
export DDNS_PUBLIC_BASE_URL='http://localhost:8080'
export DDNS_HOSTNAME_SUFFIX='karldns.de'
export DDNS_DATABASE='./ddns.sqlite3'
@@ -95,6 +96,7 @@ Open:
- ReDoc: `http://localhost:8080/redoc`
Admin auth uses HTTP Basic auth. The username can be any value; the password is `DDNS_ADMIN_PASSWORD`.
`DDNS_SHARED_SECRET`, when configured for legacy updater clients, is not accepted for admin routes.
The public OpenAPI schema documents the developer API under `/api/v1`. Admin/operator endpoints are intentionally omitted from Swagger UI and ReDoc.
@@ -103,34 +105,24 @@ The public OpenAPI schema documents the developer API under `/api/v1`. Admin/ope
For an internet-facing DynDNS service, run behind HTTPS and require a real DNS backend:
```bash
export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value'
export DDNS_ADMIN_PATH='/admin'
export DDNS_PUBLIC_BASE_URL='https://karldns.de'
export DDNS_HOSTNAME_SUFFIX='karldns.de'
export DDNS_DATABASE='/data/ddns.sqlite3'
export DDNS_TRUSTED_HOSTS='karldns.de'
export DDNS_REQUIRE_DNS_PROVIDER=1
export DDNS_DNS_ZONES='karldns.de'
export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1'
export DDNS_RATE_LIMIT_PER_MINUTE=60
export DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
export DDNS_MAX_REQUEST_BODY_BYTES=16384
export DDNS_CLEANUP_CHALLENGE_HOURS=72
export DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
export DDNS_CLEANUP_INTERVAL_SECONDS=3600
```
### Cloudflare DNS Backend
```bash
export DDNS_DNS_PROVIDER='cloudflare'
export DDNS_CLOUDFLARE_API_TOKEN='replace-with-cloudflare-token'
export DDNS_CLOUDFLARE_ZONE_ID='replace-with-cloudflare-zone-id'
export DDNS_TTL=60
```
Use a least-privilege Cloudflare API token limited to the zone KarlDNS should update.
That is the normal Cloudflare setup. Use a least-privilege Cloudflare API token limited to the zone KarlDNS should update. `DDNS_TRUSTED_HOSTS` is optional because KarlDNS automatically trusts the host in `DDNS_PUBLIC_BASE_URL`; set it only when the same instance should answer for additional public hostnames.
### RFC 2136 DNS Backend
Use this instead of the Cloudflare variables when your DNS server supports RFC 2136 dynamic updates:
```bash
export DDNS_DNS_PROVIDER='rfc2136'
export DDNS_RFC2136_SERVER='127.0.0.1'
@@ -195,6 +187,85 @@ The `/nic/update` compatibility endpoint also works for clients that expect host
curl -sS 'https://karldns.de/nic/update?hostname=home.example.net&username=router&password=<password>&myip=203.0.113.10'
```
### Linux Cron Installer
For Linux servers, Proxmox hosts, NAS boxes, and small home-lab machines that are always on, KarlDNS ships a cron updater. It creates a hostname if you do not pass one, installs a tiny updater script, schedules it with cron, runs the first update, and prints the hostname plus private dashboard URL.
System-wide install:
```bash
curl -fsSL https://karldns.de/install.sh | sudo sh
```
The same script is hosted in GitHub and can be used directly:
```bash
curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sudo sh
```
User-level install without sudo:
```bash
curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user
```
Use an existing KarlDNS update URL:
```bash
curl -fsSL https://karldns.de/install.sh | sudo sh -s -- \
--update-url 'https://karldns.de/u/<random-update-slug>' \
--hostname 'home.karldns.de'
```
Common options:
```bash
--every 5 # cron interval in minutes, default 5
--ipv4 auto # auto, off, or a static IPv4
--ipv6 auto # auto, off, or a static IPv6
--no-cron # install files only
--no-run # skip the first update
```
The updater discovers the public address through `https://karldns.de/ip` and sends it to the private update URL. If explicit discovery fails, it falls back to calling the update URL without IP parameters so KarlDNS can use the request source address. Re-running the installer is safe: it replaces the managed cron block and keeps unrelated cron entries intact.
Installed files in system mode:
```text
/usr/local/bin/karldns-update
/etc/karldns/updater.env
/etc/cron.d/karldns-updater
/var/cache/karldns/last-ip
```
Installed files in user mode:
```text
~/.local/bin/karldns-update
~/.config/karldns/updater.env
~/.cache/karldns/last-ip
```
`updater.env` is written with mode `600` because it contains the private update URL and dashboard URL. The hostname itself is not secret. The update URL and dashboard URL are secrets.
Uninstall, keeping the private config by default:
```bash
curl -fsSL https://karldns.de/uninstall.sh | sudo sh
```
Remove the private config and last-IP cache too:
```bash
curl -fsSL https://karldns.de/uninstall.sh | sudo sh -s -- --delete-config
```
For Proxmox, install on the host if the host owns the public connection. If you install inside an LXC that has no IPv6 route, use `--ipv6 off` so the cron job only updates A records:
```bash
curl -fsSL https://karldns.de/install.sh | sudo sh -s -- --ipv6 off
```
## Use Your Own Subdomain With CNAME
Most users do not need domain verification. Generate a KarlDNS hostname first, then create a CNAME at your DNS provider:
@@ -207,7 +278,7 @@ Put the KarlDNS `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` into
Use a subdomain such as `home.example.com`. Root/apex domains like `example.com` usually cannot be plain CNAME records unless your DNS provider supports CNAME flattening or ALIAS/ANAME records.
The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, router password, private status page URL, and domain claim link are the secrets.
The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, router password, private status page URL, and domain claim link are the secrets. Dashboard forms use CSRF tokens, and dashboard password unlock cookies last 24 hours by default unless the user chooses the 30-day stay-unlocked option.
## Custom Domain Flow
@@ -221,7 +292,7 @@ Custom-domain publishing is intentionally simple:
4. Press **I added it, check DNS**.
5. Create router credentials after the TXT record verifies.
KarlDNS stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. The private claim link is the bearer credential for returning later after DNS propagation.
KarlDNS stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. Claims created from a hostname dashboard are bound to that dashboard owner, so another dashboard cannot verify or use them. The private claim link is the bearer credential for returning later after DNS propagation.
## HTTP API
@@ -254,6 +325,12 @@ cp .env.example .env
docker compose up -d --build
```
The example environment is intentionally short: admin password/path, public URL, managed hostname suffix, Cloudflare token, Cloudflare zone ID, and the SQLite path. The app creates the SQLite parent directory and all tables on first run. Re-running the container or starting the app twice is idempotent; schema creation uses `CREATE TABLE IF NOT EXISTS`, and the cleanup scheduler keeps a single durable task row.
The image and compose file default `DDNS_DATABASE` to `/data/ddns.sqlite3`, and the compose volume persists `/data`.
The default container command runs one Uvicorn worker. Keep that deployment shape unless you have tested multi-worker update behavior for your traffic pattern; rate-limit counters and per-host update locks are process-local.
`Caddyfile.example` contains a minimal HTTPS reverse proxy example.
## Operations Checklist
@@ -261,13 +338,15 @@ docker compose up -d --build
- Run behind HTTPS.
- Keep `.env` out of git.
- Use a long random value for `DDNS_ADMIN_PASSWORD`.
- Change `DDNS_ADMIN_PATH` if you do not want the operator UI at `/admin`.
- Use a least-privilege DNS API token or TSIG key.
- Set `DDNS_TRUSTED_HOSTS` to the public hostnames your reverse proxy will forward, for example `karldns.de`.
- Set `DDNS_TRUSTED_HOSTS` only when the app should answer for hostnames beyond `DDNS_PUBLIC_BASE_URL`.
- Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers.
- Keep `/docs` and `/redoc` available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth.
- Leave cleanup enabled. The app periodically removes abandoned TXT challenges and generated hostnames that were never updated by a router.
- Leave cleanup enabled. The cleanup scheduler is stored in SQLite with a task row, next run time, status, last error, and a database lease so only one app process runs a due cleanup job.
- Back up `/data/ddns.sqlite3`.
- Run one Uvicorn worker with SQLite. The per-host update lock is process-local.
- Run one Uvicorn worker with SQLite unless you have tested your deployment under load. Scheduled cleanup is lease-protected across workers, but rate-limit state and per-host update locks are process-local.
- Keep Uvicorn access logs disabled unless your reverse proxy redacts query strings. Update URLs, dashboard URLs, and domain claim links are bearer secrets and may appear in request paths or query strings.
Security and cleanup defaults:
@@ -276,10 +355,14 @@ Security and cleanup defaults:
| `DDNS_RATE_LIMIT_PER_MINUTE` | `60` | Public update/API/form requests allowed per source IP and path. |
| `DDNS_ADMIN_RATE_LIMIT_PER_MINUTE` | `20` | Admin/operator requests allowed per source IP and path. |
| `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. |
| `DDNS_ADMIN_PATH` | `/admin` | Private operator UI path. Admin routes are hidden from OpenAPI and require Basic auth. |
| `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. |
| `DDNS_CLEANUP_CHALLENGE_HOURS` | `72` | Delete unverified DNS TXT claims after this many hours. |
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete generated hostnames that never received a router update after this many hours. |
| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Run the in-app cleanup scheduler at this interval. |
| `DDNS_CLEANUP_EVENT_HISTORY_HOURS` | `4320` | Delete old raw update/audit events after this many hours. Rollups and recent events remain visible. |
| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Store the cleanup task cadence in SQLite and schedule the next cleanup run at this interval. |
| `DDNS_DASHBOARD_UNLOCK_SECONDS` | `86400` | Default dashboard password unlock cookie lifetime. |
| `DDNS_DASHBOARD_STAY_UNLOCKED_SECONDS` | `2592000` | Dashboard unlock lifetime when the user chooses to stay unlocked on this browser. |
SQLite backup example:
@@ -293,6 +376,22 @@ KarlDNS is suitable for self-hosting and private/public beta use when a real DNS
## Development
Backend layout:
| Module | Purpose |
| --- | --- |
| `router_dyndns/app.py` | FastAPI app factory, lifespan, middleware and router registration. |
| `router_dyndns/ddns.py` | Compatibility exports for existing imports and CLI users. |
| `router_dyndns/routers/` | Module-level `APIRouter` files for API, public web, management, admin, assets, and DynDNS update routes. |
| `router_dyndns/dependencies.py` | Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. |
| `router_dyndns/schemas.py` | Pydantic request and response models used by the JSON API. |
| `router_dyndns/web/` | HTML rendering, form parsing, and security-header/body-limit support code. |
| `router_dyndns/ddns_service.py` | Domain logic for account creation, verification, and IP updates. |
| `router_dyndns/store/` | SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. |
| `router_dyndns/ddns_store.py` | Compatibility export for `DdnsStore`. |
| `router_dyndns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
| `router_dyndns/ddns_dns.py` | Compatibility exports for DNS provider helpers. |
```bash
ruff check router_dyndns tests
pytest
@@ -307,4 +406,6 @@ Self-hosted DynDNS, self hosted DDNS, Dynamic DNS server, FRITZ!Box DynDNS, FRIT
The repository intentionally contains only placeholders in `.env.example` and documentation. Do not commit a real `.env`, DNS token, TSIG secret, admin password, database, private status page URL, domain claim URL, or router-generated update URL. Generated hostnames and CNAME targets are public identifiers, not authentication secrets.
KarlDNS does not need secret-bearing query strings in application logs. The bundled server command leaves Uvicorn access logs disabled by default; configure reverse-proxy logs the same way or redact full request URLs.
If you find a vulnerability, open a private advisory or contact the repository owner before publishing details.
+6 -3
View File
@@ -1,13 +1,16 @@
services:
ddns:
karldns:
build: .
restart: unless-stopped
env_file:
- .env
environment:
DDNS_DATABASE: ${DDNS_DATABASE:-/data/ddns.sqlite3}
ports:
- "8080:8080"
volumes:
- ddns-data:/data
- karldns-data:/data
# Keep the default single-worker command unless multi-worker rate limiting has been tested.
healthcheck:
test: ["CMD", "python", "-c", "import os, urllib.parse, urllib.request; host=urllib.parse.urlparse(os.environ['DDNS_PUBLIC_BASE_URL']).hostname; req=urllib.request.Request('http://127.0.0.1:8080/healthz', headers={'Host': host}); urllib.request.urlopen(req, timeout=2).read()"]
interval: 30s
@@ -15,4 +18,4 @@ services:
retries: 3
volumes:
ddns-data:
karldns-data:
+3 -4
View File
@@ -27,9 +27,9 @@ dependencies = [
]
[project.urls]
Homepage = "https://github.com/StasonJatham/router-dyndns"
Repository = "https://github.com/StasonJatham/router-dyndns"
Issues = "https://github.com/StasonJatham/router-dyndns/issues"
Homepage = "https://karldns.de"
Repository = "https://github.com/StasonJatham/karldns.de"
Issues = "https://github.com/StasonJatham/karldns.de/issues"
[project.optional-dependencies]
dev = [
@@ -40,7 +40,6 @@ dev = [
[project.scripts]
karldns = "router_dyndns.cli:main"
router-dyndns = "router_dyndns.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["router_dyndns"]
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.concurrency import run_in_threadpool
from starlette.middleware.trustedhost import TrustedHostMiddleware
from .ddns_dns import DnsConfigurationError, DnsProviderError, delete_dns_records, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_service import DdnsService
from .ddns_store import DdnsStore
from .routers import admin, api, assets, management, public, updates
from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware
__all__ = [
"DdnsSettings",
"DdnsStore",
"DnsConfigurationError",
"DnsProviderError",
"UpdateResult",
"delete_dns_records",
"make_app",
"publish_dns",
]
OPENAPI_TAGS = [
{
"name": "hostnames",
"description": "Create provider-owned or verified custom DynDNS hostnames.",
},
{
"name": "domains",
"description": "Issue and verify DNS TXT challenges for custom domains.",
},
{
"name": "updates",
"description": "Router-facing IP update endpoints for FRITZ!Box and compatible clients.",
},
{
"name": "management",
"description": "Bearer-link JSON management for generated hostnames. Dashboard passwords protect only the web dashboard, not these API routes.",
},
]
CLEANUP_TASK_NAME = "cleanup"
RATE_LIMIT_FLUSH_INTERVAL_SECONDS = 10
LOGGER = logging.getLogger(__name__)
def make_app(settings: DdnsSettings | None = None) -> FastAPI:
settings = settings or DdnsSettings.from_env()
settings.validate_launch_ready()
store = DdnsStore(settings.database_path)
store.ensure_scheduled_task(CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
store.optimize()
service = DdnsService(settings, store)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
cleanup_task = asyncio.create_task(_scheduler_loop(store, settings))
rate_limit_task = asyncio.create_task(_rate_limit_flush_loop(store))
try:
yield
finally:
cleanup_task.cancel()
rate_limit_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await cleanup_task
with contextlib.suppress(asyncio.CancelledError):
await rate_limit_task
await run_in_threadpool(store.flush_rate_limits)
await run_in_threadpool(store.close_connections)
app = FastAPI(
title="KarlDNS",
summary="A small self-hosted DynDNS provider for FRITZ!Box routers.",
description=(
"Generate cryptographically random DynDNS endpoints, verify custom domains with DNS TXT "
"records, and publish A/AAAA records through Cloudflare or RFC 2136."
),
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc",
openapi_tags=OPENAPI_TAGS,
lifespan=lifespan,
)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.effective_trusted_hosts)
app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes)
app.state.settings = settings
app.state.store = store
app.state.service = service
install_security_middleware(app, settings, store)
app.include_router(api.router)
app.include_router(assets.router)
app.include_router(public.router)
app.include_router(management.router)
app.include_router(admin.make_router(settings.admin_path))
app.include_router(updates.router)
return app
async def _scheduler_loop(store: DdnsStore, settings: DdnsSettings) -> None:
poll_seconds = min(settings.cleanup_interval_seconds, 60)
while True:
await _run_cleanup_if_due(store, settings)
await asyncio.sleep(poll_seconds)
async def _rate_limit_flush_loop(store: DdnsStore) -> None:
while True:
await asyncio.sleep(RATE_LIMIT_FLUSH_INTERVAL_SECONDS)
await run_in_threadpool(store.flush_rate_limits)
await run_in_threadpool(store.prune_rate_limit_storage)
await run_in_threadpool(store.checkpoint_wal)
async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None:
lease_owner = f"{os.getpid()}:{id(store)}"
lease_seconds = max(60, settings.cleanup_interval_seconds)
acquired = await run_in_threadpool(
store.acquire_due_task,
CLEANUP_TASK_NAME,
lease_owner,
lease_seconds,
)
if not acquired:
return
try:
await run_in_threadpool(
store.cleanup,
settings.cleanup_challenge_hours,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
)
except Exception as exc:
LOGGER.exception("Scheduled cleanup failed")
await run_in_threadpool(
store.complete_scheduled_task,
CLEANUP_TASK_NAME,
settings.cleanup_interval_seconds,
False,
f"{type(exc).__name__}: {exc}",
)
return
await run_in_threadpool(
store.complete_scheduled_task,
CLEANUP_TASK_NAME,
settings.cleanup_interval_seconds,
True,
"",
)
await run_in_threadpool(store.optimize)
+6 -2
View File
@@ -17,6 +17,11 @@ def main() -> None:
serve = subparsers.add_parser("serve", help="Run the DDNS HTTP service.")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=8080)
serve.add_argument(
"--access-log",
action="store_true",
help="Enable Uvicorn access logs. Disabled by default because update URLs are bearer secrets.",
)
args = parser.parse_args()
if args.command == "render-fritz-url":
@@ -27,7 +32,7 @@ def main() -> None:
settings = DdnsSettings.from_env()
if not settings.admin_password:
raise SystemExit("DDNS_ADMIN_PASSWORD must be set.")
uvicorn.run(make_app(settings), host=args.host, port=args.port)
uvicorn.run(make_app(settings), host=args.host, port=args.port, access_log=args.access_log)
def _render_fritz_url(base_url: str, host: str, include_ipv6: bool) -> str:
@@ -40,4 +45,3 @@ def _render_fritz_url(base_url: str, host: str, include_ipv6: bool) -> str:
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import os
from pathlib import Path
from .ddns_models import DdnsSettings
def settings_from_env() -> DdnsSettings:
return DdnsSettings(
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
shared_secret=os.getenv("DDNS_SHARED_SECRET", ""),
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", ""),
admin_path=os.getenv("DDNS_ADMIN_PATH", "/admin"),
allowed_hosts=_csv_set("DDNS_ALLOWED_HOSTS"),
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
trusted_hosts=_csv_set("DDNS_TRUSTED_HOSTS"),
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_karldns"),
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20),
max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
trusted_proxy_ips=_csv_set("DDNS_TRUSTED_PROXY_IPS"),
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
rfc2136_key_name=os.getenv("DDNS_RFC2136_KEY_NAME") or None,
rfc2136_key_secret=os.getenv("DDNS_RFC2136_KEY_SECRET") or None,
dns_provider=os.getenv("DDNS_DNS_PROVIDER", ""),
dns_zones=_csv_set("DDNS_DNS_ZONES"),
require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"),
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72),
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080),
cleanup_event_history_hours=_env_int("DDNS_CLEANUP_EVENT_HISTORY_HOURS", 24 * 180),
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
dashboard_unlock_seconds=_env_int("DDNS_DASHBOARD_UNLOCK_SECONDS", 24 * 60 * 60),
dashboard_stay_unlocked_seconds=_env_int(
"DDNS_DASHBOARD_STAY_UNLOCKED_SECONDS",
30 * 24 * 60 * 60,
),
ttl=_env_int("DDNS_TTL", 60),
)
def _csv_set(name: str) -> set[str]:
return {item.strip().lower() for item in os.getenv(name, "").split(",") if item.strip()}
def _env_flag(name: str) -> bool:
return os.getenv(name, "0").strip().lower() not in {"0", "false", "no"}
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)).strip())
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
+4 -1552
View File
File diff suppressed because it is too large. Load diff
+24 -242
View File
@@ -1,245 +1,27 @@
import sqlite3
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from .ddns_models import DdnsSettings
from .ddns_service import DdnsService, normalize_domain
from .ddns_store import DdnsStore
class ErrorResponse(BaseModel):
detail: str = Field(examples=["not found"])
class MagicHostnameRequest(BaseModel):
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class HostnameCredentials(BaseModel):
hostname: str
username: str
password: str
update_url: str
credentialed_update_url: str
management_url: str
class ManagedRecord(BaseModel):
hostname: str
username: str
update_url: str
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
class DomainChallengeRequest(BaseModel):
domain: str = Field(examples=["example.net"])
class DomainChallengeResponse(BaseModel):
domain: str
txt_name: str
txt_value: str
claim_secret: str
class VerifyDomainRequest(BaseModel):
domain: str = Field(examples=["example.net"])
claim_secret: str
class VerifyDomainResponse(BaseModel):
domain: str
verified: bool
message: str
claim_secret: str
class CustomHostnameRequest(BaseModel):
hostname: str = Field(examples=["home.example.net"])
claim_secret: str
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class RouterUpdateResponse(BaseModel):
status: str = Field(examples=["good"])
hostname: str
ip: str
def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsService) -> APIRouter:
router = APIRouter(prefix="/api/v1")
@router.post(
"/hostnames/magic",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate a provider-owned DynDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_magic_hostname(payload: MagicHostnameRequest) -> HostnameCredentials:
account = await run_in_threadpool(service.create_managed_account, payload.username)
return _credentials_response(service, account)
@router.get(
"/management/{management_slug}",
response_model=ManagedRecord,
summary="Read a hostname by magic management link",
tags=["management"],
responses={404: {"model": ErrorResponse}},
)
async def read_managed_hostname(management_slug: str) -> ManagedRecord:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return _managed_record(service, account)
@router.delete(
"/management/{management_slug}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by magic management link",
tags=["management"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_managed_hostname(management_slug: str) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "api")
except Exception as exc:
raise HTTPException(status_code=500, detail="DNS delete failed; hostname was not deleted") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/domains/challenges",
response_model=DomainChallengeResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a DNS TXT domain verification challenge",
tags=["domains"],
responses={400: {"model": ErrorResponse}},
)
async def create_domain_challenge(
payload: DomainChallengeRequest,
) -> DomainChallengeResponse:
domain = normalize_domain(payload.domain)
try:
challenge = await run_in_threadpool(service.create_domain_challenge, domain)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
return DomainChallengeResponse(
domain=domain,
txt_name=service.verification_name(domain),
txt_value=challenge["token"],
claim_secret=challenge["claim_secret"],
from .schemas import (
CustomHostnameRequest,
DomainChallengeRequest,
DomainChallengeResponse,
ErrorResponse,
HostnameCredentials,
MagicHostnameRequest,
ManagedRecord,
RouterUpdateResponse,
VerifyDomainRequest,
VerifyDomainResponse,
)
@router.post(
"/domains/verify",
response_model=VerifyDomainResponse,
summary="Verify a domain TXT challenge",
tags=["domains"],
responses={404: {"model": ErrorResponse}},
)
async def verify_domain(
payload: VerifyDomainRequest,
) -> VerifyDomainResponse:
domain = normalize_domain(payload.domain)
found, challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret)
if not found:
return VerifyDomainResponse(
domain=domain,
verified=False,
message="TXT record not found yet",
claim_secret=payload.claim_secret,
)
return VerifyDomainResponse(
domain=domain,
verified=True,
message="domain verified",
claim_secret=payload.claim_secret,
)
@router.post(
"/hostnames/custom",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate credentials for a verified custom hostname",
tags=["hostnames"],
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def create_custom_hostname(
payload: CustomHostnameRequest,
) -> HostnameCredentials:
account = await run_in_threadpool(
service.create_custom_account,
payload.hostname,
payload.claim_secret,
payload.username,
)
return _credentials_response(service, account)
@router.get(
"/updates/{update_slug}",
response_model=RouterUpdateResponse,
summary="Router-compatible update endpoint",
tags=["updates"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def update_hostname(
update_slug: str,
request: Request,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RouterUpdateResponse:
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
outcome = await run_in_threadpool(
service.apply_update,
request,
hostname,
myip,
ipaddr,
myipv6,
ip6addr,
)
except Exception as exc:
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
result = outcome.result
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
return router
def _credentials_response(service: DdnsService, account: dict[str, str]) -> HostnameCredentials:
return HostnameCredentials(
hostname=account["hostname"],
username=account["username"],
password=account["password"],
update_url=service.fritz_update_url(account),
credentialed_update_url=service.credentialed_update_url(account),
management_url=service.magic_management_url(account),
)
def _managed_record(service: DdnsService, account: dict[str, str | int | None]) -> ManagedRecord:
account_for_url = {"update_slug": str(account["update_slug"])}
return ManagedRecord(
hostname=str(account["hostname"]),
username=str(account["username"]),
update_url=service.fritz_update_url(account_for_url),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
)
__all__ = [
"CustomHostnameRequest",
"DomainChallengeRequest",
"DomainChallengeResponse",
"ErrorResponse",
"HostnameCredentials",
"MagicHostnameRequest",
"ManagedRecord",
"RouterUpdateResponse",
"VerifyDomainRequest",
"VerifyDomainResponse",
]
+24 -152
View File
@@ -1,155 +1,27 @@
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from .dns import (
DnsConfigurationError,
DnsProviderError,
delete_cloudflare_records,
delete_dns_records,
delete_rfc2136_records,
dns_backend_configured,
publish_cloudflare,
publish_dns,
publish_rfc2136,
validate_dns_provider_settings,
)
from .ddns_models import DdnsSettings, UpdateResult
def publish_dns(result: UpdateResult, settings: DdnsSettings) -> None:
provider = settings.dns_provider.strip().lower()
if provider == "cloudflare":
publish_cloudflare(result, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
publish_rfc2136(result, settings)
return
if settings.require_dns_provider:
raise RuntimeError("DNS provider is required but not configured")
def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
provider = settings.dns_provider.strip().lower()
if provider == "cloudflare":
delete_cloudflare_records(hostname, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
delete_rfc2136_records(hostname, settings)
return
if settings.require_dns_provider:
raise RuntimeError("DNS provider is required but not configured")
def dns_backend_configured(settings: DdnsSettings) -> bool:
provider = settings.dns_provider.strip().lower()
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.query
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(result.hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = result.hostname.removesuffix("." + zone).rstrip(".") or "@"
if result.update_ipv4 and result.ipv4:
update.replace(relative, settings.ttl, "A", result.ipv4)
elif result.update_ipv4:
update.delete(relative, "A")
if result.update_ipv6 and result.ipv6:
update.replace(relative, settings.ttl, "AAAA", result.ipv6)
elif result.update_ipv6:
update.delete(relative, "AAAA")
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
rcode = response.rcode()
if rcode != 0:
import dns.rcode
raise RuntimeError(f"RFC2136 update failed: {dns.rcode.to_text(rcode)}")
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.query
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = hostname.removesuffix("." + zone).rstrip(".") or "@"
update.delete(relative, "A")
update.delete(relative, "AAAA")
dns.query.tcp(update, settings.rfc2136_server, timeout=5)
def publish_cloudflare(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise RuntimeError("DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required")
if result.update_ipv4:
_cloudflare_upsert_record(settings, result.hostname, "A", result.ipv4)
if result.update_ipv6:
_cloudflare_upsert_record(settings, result.hostname, "AAAA", result.ipv6)
def delete_cloudflare_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise RuntimeError("DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required")
for record_type in ("A", "AAAA"):
record = _cloudflare_find_record(settings, hostname, record_type)
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
def _cloudflare_upsert_record(settings: DdnsSettings, hostname: str, record_type: str, value: str | None) -> None:
record = _cloudflare_find_record(settings, hostname, record_type)
if value is None:
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
return
payload = {
"type": record_type,
"name": hostname,
"content": value,
"ttl": settings.ttl,
"proxied": False,
}
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="PATCH", payload=payload)
else:
_cloudflare_request(settings, "/dns_records", method="POST", payload=payload)
def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, str] | None:
query = urllib.parse.urlencode({"type": record_type, "name": hostname})
response = _cloudflare_request(settings, f"/dns_records?{query}")
records = response.get("result", [])
return records[0] if records else None
def _cloudflare_request(
settings: DdnsSettings,
path: str,
method: str = "GET",
payload: dict[str, object] | None = None,
) -> dict[str, object]:
url = f"https://api.cloudflare.com/client/v4/zones/{settings.cloudflare_zone_id}{path}"
body = json.dumps(payload).encode("utf-8") if payload is not None else None
request = urllib.request.Request(url, data=body, method=method)
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=10) as response:
parsed = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Cloudflare API {exc.code}: {detail}") from exc
if not parsed.get("success", False):
raise RuntimeError(f"Cloudflare API error: {parsed}")
return parsed
__all__ = [
"DnsConfigurationError",
"DnsProviderError",
"delete_cloudflare_records",
"delete_dns_records",
"delete_rfc2136_records",
"dns_backend_configured",
"publish_cloudflare",
"publish_dns",
"publish_rfc2136",
"validate_dns_provider_settings",
]
+46 -47
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
import os
import re
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
@@ -12,6 +12,7 @@ class DdnsSettings(BaseModel):
database_path: Path = Field(default=Path("ddns.sqlite3"))
shared_secret: str = Field(default="")
admin_password: str = Field(default="")
admin_path: str = Field(default="/admin")
allowed_hosts: set[str] = Field(default_factory=set)
public_base_url: str = Field(default="http://localhost:8080")
trusted_hosts: set[str] = Field(default_factory=set)
@@ -32,7 +33,10 @@ class DdnsSettings(BaseModel):
cloudflare_zone_id: str = Field(default="")
cleanup_challenge_hours: int = Field(default=72, ge=1, le=24 * 365)
cleanup_unused_account_hours: int = Field(default=1080, ge=1, le=24 * 365)
cleanup_event_history_hours: int = Field(default=24 * 180, ge=1, le=24 * 3650)
cleanup_interval_seconds: int = Field(default=3600, ge=1, le=24 * 60 * 60)
dashboard_unlock_seconds: int = Field(default=24 * 60 * 60, ge=60, le=30 * 24 * 60 * 60)
dashboard_stay_unlocked_seconds: int = Field(default=30 * 24 * 60 * 60, ge=60, le=30 * 24 * 60 * 60)
ttl: int = Field(default=60, ge=60, le=86400)
@field_validator("public_base_url")
@@ -47,6 +51,41 @@ class DdnsSettings(BaseModel):
def validate_trusted_hosts(cls, value: set[str]) -> set[str]:
return {host.strip().lower() for host in value if host.strip()}
@field_validator("admin_path")
@classmethod
def validate_admin_path(cls, value: str) -> str:
path = value.strip()
if not path.startswith("/") or path == "/" or path.endswith("/"):
raise ValueError("admin_path must start with / and must not be / or end with /")
if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path):
raise ValueError("admin_path may only contain URL-safe path characters")
reserved_paths = {
"/accounts",
"/api",
"/app.js",
"/claim",
"/d",
"/docs",
"/favicon.ico",
"/healthz",
"/install.sh",
"/ip",
"/logo.png",
"/m",
"/magic",
"/nic",
"/openapi.json",
"/privacy",
"/redoc",
"/robots.txt",
"/site.webmanifest",
"/u",
"/uninstall.sh",
}
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
raise ValueError("admin_path must not conflict with a public route")
return path
@property
def effective_trusted_hosts(self) -> list[str]:
hosts = set(self.trusted_hosts)
@@ -58,6 +97,9 @@ class DdnsSettings(BaseModel):
return sorted(hosts)
def validate_launch_ready(self) -> None:
from .dns import validate_dns_provider_settings
validate_dns_provider_settings(self)
if not self.require_dns_provider:
return
if len(self.admin_password) < 16:
@@ -69,41 +111,9 @@ class DdnsSettings(BaseModel):
@classmethod
def from_env(cls) -> DdnsSettings:
allowed = {
item.strip().lower()
for item in os.getenv("DDNS_ALLOWED_HOSTS", "").split(",")
if item.strip()
}
trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS")
trusted_hosts = _csv_set("DDNS_TRUSTED_HOSTS")
dns_zones = _csv_set("DDNS_DNS_ZONES")
return cls(
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
shared_secret=os.getenv("DDNS_SHARED_SECRET", ""),
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", os.getenv("DDNS_SHARED_SECRET", "")),
allowed_hosts=allowed,
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
trusted_hosts=trusted_hosts,
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_karldns"),
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20),
max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
trusted_proxy_ips=trusted_proxy_ips,
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
rfc2136_key_name=os.getenv("DDNS_RFC2136_KEY_NAME") or None,
rfc2136_key_secret=os.getenv("DDNS_RFC2136_KEY_SECRET") or None,
dns_provider=os.getenv("DDNS_DNS_PROVIDER", ""),
dns_zones=dns_zones,
require_dns_provider=os.getenv("DDNS_REQUIRE_DNS_PROVIDER", "0").strip().lower() not in {"0", "false", "no"},
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72),
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080),
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
ttl=_env_int("DDNS_TTL", 60),
)
from .config import settings_from_env
return settings_from_env()
@dataclass(slots=True)
@@ -119,14 +129,3 @@ class UpdateResult:
def dyndns_response(self) -> str:
ip = self.ipv4 or self.ipv6 or "0.0.0.0"
return f"{'good' if self.changed else 'nochg'} {ip}"
def _csv_set(name: str) -> set[str]:
return {item.strip().lower() for item in os.getenv(name, "").split(",") if item.strip()}
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except ValueError:
return default
+66 -3
View File
@@ -4,6 +4,7 @@ import base64
import hashlib
import hmac
import secrets
import time
from datetime import UTC, datetime
from ipaddress import ip_address, ip_network
@@ -11,6 +12,10 @@ from fastapi import HTTPException, Request
from .ddns_models import DdnsSettings
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
try:
@@ -44,10 +49,68 @@ def hash_lookup_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def admin_csrf_token(settings: DdnsSettings) -> str:
secret = settings.admin_password or settings.shared_secret
def sign_dashboard_unlock_cookie(
hostname: str,
management_slug: str,
password_hash: str,
max_age_seconds: int,
now: int | None = None,
) -> str:
timestamp = int(time.time() if now is None else now)
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
signature = _dashboard_unlock_signature(hostname, management_slug, password_hash, timestamp, bounded_max_age)
return f"v2:{timestamp}:{bounded_max_age}:{signature}"
def verify_dashboard_unlock_cookie(hostname: str, management_slug: str, password_hash: str | None, cookie_value: str | None) -> bool:
if not password_hash or not cookie_value:
return False
try:
version, raw_timestamp, raw_max_age, supplied = cookie_value.split(":", 3)
timestamp = int(raw_timestamp)
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
except ValueError:
return False
if version != "v2":
return False
now = int(time.time())
if timestamp > now + 60 or now - timestamp > max_age_seconds:
return False
expected = _dashboard_unlock_signature(hostname, management_slug, password_hash, timestamp, max_age_seconds)
return hmac.compare_digest(supplied, expected)
def _dashboard_unlock_signature(
hostname: str,
management_slug: str,
password_hash: str,
timestamp: int,
max_age_seconds: int,
) -> str:
payload = f"{hostname}\0{management_slug}\0{timestamp}\0{max_age_seconds}".encode()
return hmac.new(password_hash.encode(), payload, hashlib.sha256).hexdigest()
def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | None]) -> str:
hostname = str(account["hostname"])
password_hash = str(account.get("dashboard_password_hash") or "")
day = datetime.now(UTC).strftime("%Y-%m-%d")
return hmac.new(secret.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest()
key = f"{management_slug}\0{password_hash}".encode()
return hmac.new(key, f"dashboard:{hostname}:{day}".encode(), hashlib.sha256).hexdigest()
def require_dashboard_csrf(management_slug: str, account: dict[str, str | int | None], supplied: str) -> None:
if not supplied or not hmac.compare_digest(supplied, dashboard_csrf_token(management_slug, account)):
raise HTTPException(status_code=403, detail="invalid csrf token")
def _bounded_dashboard_unlock_seconds(max_age_seconds: int) -> int:
return max(60, min(max_age_seconds, DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS))
def admin_csrf_token(settings: DdnsSettings) -> str:
day = datetime.now(UTC).strftime("%Y-%m-%d")
return hmac.new(settings.admin_password.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest()
def require_admin_csrf(settings: DdnsSettings, supplied: str) -> None:
+92 -41
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import ipaddress
import logging
import re
import secrets
import sqlite3
@@ -10,10 +11,13 @@ from dataclasses import dataclass
from fastapi import HTTPException, Request
from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns
from .ddns_dns import DnsProviderError, delete_dns_records, dns_backend_configured, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_security import client_ip
from .ddns_store import DdnsStore
from .event_statuses import DNS_DELETE_FAILED, DNS_FAILED, UNCHANGED, UPDATED
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
@@ -22,12 +26,17 @@ class UpdateOutcome:
source_ip: str
class BadIpError(ValueError):
"""Raised when a supplied DynDNS IP parameter is invalid."""
class DdnsService:
_LOCK_STRIPES = 256
def __init__(self, settings: DdnsSettings, store: DdnsStore) -> None:
self.settings = settings
self.store = store
self._locks_guard = threading.Lock()
self._locks: dict[str, threading.Lock] = {}
self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
def create_managed_account(self, username: str | None = None) -> dict[str, str]:
if not self.settings.hostname_suffix:
@@ -44,33 +53,39 @@ class DdnsService:
hostname: str,
claim_secret: str,
username: str | None = None,
owner_hostname: str | None = None,
) -> dict[str, str]:
normalized = normalize_hostname(hostname, self.settings, expand_suffix=False)
if not normalized:
raise HTTPException(status_code=400, detail="valid custom hostname is required")
if not self.hostname_is_publishable(normalized):
raise HTTPException(status_code=403, detail="hostname is outside configured DNS publishing zones")
if not self.best_verified_parent(normalized, claim_secret):
if not self.best_verified_parent(normalized, claim_secret, owner_hostname=owner_hostname):
raise HTTPException(status_code=403, detail="verify the parent domain before creating this hostname")
try:
return self.store.create_account(normalized, username)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="hostname already exists") from exc
def create_domain_challenge(self, domain: str) -> dict[str, str]:
def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> dict[str, str]:
normalized = normalize_domain(domain)
if not normalized:
raise HTTPException(status_code=400, detail="valid domain is required")
existing = self.store.get_domain_challenge(normalized)
if existing and existing.get("verified_at"):
raise HTTPException(status_code=409, detail="domain is already verified")
return self.store.create_domain_challenge(normalized)
return self.store.create_domain_challenge(normalized, owner_hostname=owner_hostname)
def verify_domain(self, domain: str, claim_secret: str) -> tuple[bool, dict[str, str | None]]:
def verify_domain(
self,
domain: str,
claim_secret: str,
owner_hostname: str | None = None,
) -> tuple[bool, dict[str, str | None]]:
normalized = normalize_domain(domain)
if not normalized:
raise HTTPException(status_code=400, detail="valid domain is required")
challenge = self.store.get_domain_challenge(normalized, claim_secret=claim_secret)
challenge = self.store.get_domain_challenge(normalized, claim_secret=claim_secret, owner_hostname=owner_hostname)
if not challenge:
raise HTTPException(status_code=404, detail="domain challenge not found")
found = dns_txt_contains(self.verification_name(normalized), str(challenge["token"]))
@@ -78,18 +93,22 @@ class DdnsService:
challenge = dict(challenge)
challenge["claim_secret"] = claim_secret
return False, challenge
verified = self.store.verify_domain_challenge(normalized)
verified = self.store.verify_domain_challenge(normalized, claim_secret, owner_hostname=owner_hostname)
if verified is None:
raise HTTPException(status_code=404, detail="domain challenge not found")
verified = dict(verified)
verified["claim_secret"] = claim_secret
return True, verified
def delete_account(self, hostname: str, source: str) -> None:
try:
delete_dns_records(hostname, self.settings)
except Exception as exc:
self.store.log_update_event(hostname, None, None, "dns_delete_failed", f"{type(exc).__name__}: {exc}", source)
except DnsProviderError as exc:
LOGGER.warning(
"DNS delete failed for hostname=%s source=%s error_type=%s",
hostname,
source,
type(exc).__name__,
)
self.store.log_update_event(hostname, None, None, DNS_DELETE_FAILED, f"{type(exc).__name__}: {exc}", source)
raise
self.store.delete_account(hostname)
@@ -102,19 +121,35 @@ class DdnsService:
myipv6: str | None,
ip6addr: str | None,
) -> UpdateOutcome:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(request, myip, ipaddr, myipv6, ip6addr)
source_ip = client_ip(request, self.settings)
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_ip,
myip,
ipaddr,
myipv6,
ip6addr,
)
lock = self._lock_for(hostname)
with lock:
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
if planned.changed:
try:
publish_dns(planned, self.settings)
except Exception as exc:
self.store.log_update_event(hostname, ipv4, ipv6, "dns_failed", f"{type(exc).__name__}: {exc}", source_ip)
except DnsProviderError as exc:
LOGGER.warning(
"DNS publish failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
hostname,
planned.update_ipv4,
planned.update_ipv6,
type(exc).__name__,
)
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
raise
result = self.store.upsert(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, "updated" if result.changed else "unchanged", "ok", source_ip)
result = self.store.commit_update_result(planned)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "ok", source_ip)
else:
result = planned
self.store.log_update_rollup(hostname, result.ipv4, result.ipv6, UNCHANGED, "ok", source_ip)
return UpdateOutcome(result=result, source_ip=source_ip)
def fritz_update_url(self, account: dict[str, str]) -> str:
@@ -149,13 +184,18 @@ class DdnsService:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
return f"{secrets.token_hex(6)}.{suffix}"
def best_verified_parent(self, hostname: str, claim_secret: str | None = None) -> str | None:
def best_verified_parent(
self,
hostname: str,
claim_secret: str | None = None,
owner_hostname: str | None = None,
) -> str | None:
labels = hostname.split(".")
for index in range(1, len(labels) - 1):
candidate = ".".join(labels[index:])
if self.store.is_domain_verified(candidate, claim_secret):
if self.store.is_domain_verified(candidate, claim_secret, owner_hostname=owner_hostname):
return candidate
if self.store.is_domain_verified(hostname, claim_secret):
if self.store.is_domain_verified(hostname, claim_secret, owner_hostname=owner_hostname):
return hostname
return None
@@ -178,19 +218,14 @@ class DdnsService:
return bool(zones) and any(hostname == zone or hostname.endswith(f".{zone}") for zone in zones)
def _lock_for(self, hostname: str) -> threading.Lock:
with self._locks_guard:
lock = self._locks.get(hostname)
if lock is None:
lock = threading.Lock()
self._locks[hostname] = lock
return lock
return self._locks[hash(hostname) % self._LOCK_STRIPES]
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
if not value:
if not value or not value.strip():
return None
try:
ip = ipaddress.ip_address(value)
ip = ipaddress.ip_address(value.strip())
except ValueError:
return None
if version and ip.version != version:
@@ -199,27 +234,42 @@ def normalize_ip(value: str | None, version: int | None = None) -> str | None:
def parse_update_ips(
request: Request,
source_ip: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> tuple[str | None, str | None, bool, bool]:
raw_ipv4 = myip if myip is not None else ipaddr
raw_ipv6 = myipv6 if myipv6 is not None else ip6addr
update_ipv4 = raw_ipv4 is not None
update_ipv6 = raw_ipv6 is not None
ipv4 = normalize_ip(raw_ipv4, version=4)
ipv6 = normalize_ip(raw_ipv6, version=6)
if not update_ipv4 and not update_ipv6:
peer = request.client.host if request.client else None
ipv4 = normalize_ip(peer, version=4)
ipv6 = normalize_ip(peer, version=6)
ipv4 = _first_valid_ip((("myip", myip), ("ipaddr", ipaddr)), version=4)
ipv6 = _first_valid_ip((("myipv6", myipv6), ("ip6addr", ip6addr)), version=6)
update_ipv4 = ipv4 is not None
update_ipv6 = ipv6 is not None
if not update_ipv4 and not update_ipv6:
ipv4 = normalize_ip(source_ip, version=4)
ipv6 = normalize_ip(source_ip, version=6)
update_ipv4 = ipv4 is not None
update_ipv6 = ipv6 is not None
if not update_ipv4 and not update_ipv6:
raise BadIpError("source IP is not a valid IPv4 or IPv6 address")
return ipv4, ipv6, update_ipv4, update_ipv6
def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) -> str | None:
selected = None
for name, value in params:
if value is None or not value.strip():
continue
try:
ip = ipaddress.ip_address(value.strip())
except ValueError as exc:
raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
if ip.version != version:
raise BadIpError(f"{name} must be an IPv{version} address")
if selected is None:
selected = str(ip)
return selected
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
hostname = value.strip().lower().rstrip(".")
suffix = settings.hostname_suffix.strip().lower().strip(".")
@@ -237,10 +287,11 @@ def normalize_domain(value: str) -> str:
def dns_txt_contains(name: str, expected: str) -> bool:
try:
import dns.exception
import dns.resolver
answers = dns.resolver.resolve(name, "TXT", lifetime=5)
except Exception:
except dns.exception.DNSException:
return False
for answer in answers:
for item in answer.strings:
+2 -509
View File
@@ -1,512 +1,5 @@
from __future__ import annotations
import secrets
import sqlite3
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from .store import DdnsStore
from .ddns_models import UpdateResult
from .ddns_security import hash_lookup_token, hash_secret, verify_secret
class DdnsStore:
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.database_path)
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 5000")
return conn
def upsert(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(hostname, effective_ipv4, effective_ipv6, now),
)
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def preview_update(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def create_account(self, hostname: str, username: str | None = None) -> dict[str, str]:
now = datetime.now(UTC).isoformat()
token = secrets.token_urlsafe(24)
update_slug = secrets.token_urlsafe(32)
management_slug = secrets.token_urlsafe(32)
username = username or _default_username(hostname)
token_hash = hash_secret(token)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute(
"""
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
VALUES (?, ?, ?, ?, ?, ?, 0)
""",
(hostname, username, token_hash, update_slug, management_hash, now),
)
return {
"hostname": hostname,
"username": username,
"password": token,
"update_slug": update_slug,
"management_slug": management_slug,
}
def list_accounts(self) -> list[dict[str, str | int | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname
"""
).fetchall()
return [dict(row) for row in rows]
def get_account_by_hostname(self, hostname: str) -> dict[str, str | int | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
""",
(hostname,),
).fetchone()
return dict(account) if account else None
def get_account_by_slug(self, update_slug: str) -> dict[str, str | int | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
WHERE update_slug = ?
""",
(update_slug,),
).fetchone()
return dict(account) if account else None
def get_account_by_management_slug(self, management_slug: str) -> dict[str, str | int | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ?
""",
(hash_lookup_token(management_slug),),
).fetchone()
return dict(account) if account else None
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool:
if not username or not token:
return False
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT token_hash, disabled FROM accounts
WHERE hostname = ? AND username = ?
""",
(hostname, username),
).fetchone()
if not account or account["disabled"]:
return False
return verify_secret(token, account["token_hash"])
def delete_account(self, hostname: str) -> None:
with self.connect() as conn:
conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,))
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
def rotate_update_slug(self, hostname: str) -> dict[str, str | int | None] | None:
update_slug = secrets.token_urlsafe(32)
with self.connect() as conn:
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
return self.get_account_by_hostname(hostname)
def rotate_management_slug(self, hostname: str) -> dict[str, str | int | None] | None:
management_slug = secrets.token_urlsafe(32)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["management_slug"] = management_slug
return account
def rotate_password(self, hostname: str) -> dict[str, str | int | None] | None:
token = secrets.token_urlsafe(24)
token_hash = hash_secret(token)
with self.connect() as conn:
conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["password"] = token
return account
def create_domain_challenge(self, domain: str) -> dict[str, str]:
token = f"ff-ddns-{secrets.token_urlsafe(24)}"
claim_secret = secrets.token_urlsafe(32)
claim_hash = hash_lookup_token(claim_secret)
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
existing = conn.execute("SELECT verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
if existing and existing[0]:
raise sqlite3.IntegrityError("domain is already verified")
conn.execute(
"""
INSERT INTO domain_challenges(domain, token, claim_hash, created_at, verified_at)
VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(domain) DO UPDATE SET
token=excluded.token,
claim_hash=excluded.claim_hash,
created_at=excluded.created_at,
verified_at=NULL
""",
(domain, token, claim_hash, now),
)
return {"domain": domain, "token": token, "claim_secret": claim_secret}
def verify_domain_challenge(self, domain: str) -> dict[str, str | None] | None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.row_factory = sqlite3.Row
challenge = conn.execute("SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
if not challenge:
return None
conn.execute("UPDATE domain_challenges SET verified_at = ? WHERE domain = ?", (now, domain))
result = dict(challenge)
result["verified_at"] = now
return result
def get_domain_challenge(self, domain: str, claim_secret: str | None = None) -> dict[str, str | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
sql = "SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?"
params = (domain,)
if claim_secret is not None:
sql += " AND claim_hash = ?"
params = (domain, hash_lookup_token(claim_secret))
challenge = conn.execute(sql, params).fetchone()
return dict(challenge) if challenge else None
def get_domain_challenge_by_secret(self, claim_secret: str) -> dict[str, str | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
challenge = conn.execute(
"SELECT domain, token, verified_at FROM domain_challenges WHERE claim_hash = ?",
(hash_lookup_token(claim_secret),),
).fetchone()
if not challenge:
return None
result = dict(challenge)
result["claim_secret"] = claim_secret
return result
def list_domain_challenges(self, limit: int = 100) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT domain, created_at, verified_at
FROM domain_challenges
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def is_domain_verified(self, domain: str, claim_secret: str | None = None) -> bool:
with self.connect() as conn:
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
params = (domain,)
if claim_secret is not None:
sql += " AND claim_hash = ?"
params = (domain, hash_lookup_token(claim_secret))
verified_at = conn.execute(sql, params).fetchone()
return bool(verified_at and verified_at[0])
def list_records(self) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT hostname, ipv4, ipv6, updated_at FROM records ORDER BY hostname").fetchall()
return [dict(row) for row in rows]
def log_update_event(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()),
)
def list_update_events(self, limit: int = 100) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at
FROM update_events
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def list_update_events_for_hostname(self, hostname: str, limit: int = 25) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at
FROM update_events
WHERE hostname = ?
ORDER BY created_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
return [dict(row) for row in rows]
def list_cleanup_runs(self, limit: int = 20) -> list[dict[str, str | int]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
FROM cleanup_runs
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def allow_rate_limit(self, key: str, limit: int) -> bool:
now = int(time.time())
window = now // 60
with self.connect() as conn:
conn.execute("DELETE FROM rate_limits WHERE window < ?", (window - 2,))
row = conn.execute(
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
(key, window),
).fetchone()
count = int(row[0]) if row else 0
if count >= limit:
return False
conn.execute(
"""
INSERT INTO rate_limits(key, window, count)
VALUES (?, ?, 1)
ON CONFLICT(key, window) DO UPDATE SET count=count + 1
""",
(key, window),
)
return True
def cleanup(self, challenge_hours: int, unused_account_hours: int) -> dict[str, int]:
challenge_cutoff = (datetime.now(UTC) - timedelta(hours=challenge_hours)).isoformat()
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
with self.connect() as conn:
challenge_cursor = conn.execute(
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?",
(challenge_cutoff,),
)
account_cursor = conn.execute(
"""
DELETE FROM accounts
WHERE created_at < ?
AND NOT EXISTS (
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
)
""",
(unused_account_cutoff,),
)
conn.execute("DELETE FROM rate_limits WHERE window < ?", (int(time.time()) - 3600,))
conn.execute(
"""
INSERT INTO cleanup_runs(created_at, domain_challenges_deleted, unused_accounts_deleted)
VALUES (?, ?, ?)
""",
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
)
return {
"domain_challenges": challenge_cursor.rowcount,
"unused_accounts": account_cursor.rowcount,
}
def _init_db(self) -> None:
with self.connect() as conn:
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS records (
hostname TEXT PRIMARY KEY,
ipv4 TEXT,
ipv6 TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ipv4 TEXT,
ipv6 TEXT,
status TEXT NOT NULL,
detail TEXT NOT NULL,
source_ip TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
domain_challenges_deleted INTEGER NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
window INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY(key, window)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE,
created_at TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
_ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT")
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (hash_lookup_token(secrets.token_urlsafe(32)), row[0]))
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS domain_challenges (
domain TEXT PRIMARY KEY,
token TEXT NOT NULL,
claim_hash TEXT,
created_at TEXT NOT NULL,
verified_at TEXT
)
"""
)
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
def _default_username(hostname: str) -> str:
label = hostname.split(".", 1)[0]
import re
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
if column not in columns:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
__all__ = ["DdnsStore"]
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import secrets
from typing import Annotated
from fastapi import Depends, Header, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from .ddns_dns import DnsProviderError
from .ddns_models import DdnsSettings
from .ddns_security import credentials_from_basic_auth
from .ddns_service import BadIpError, DdnsService
from .ddns_store import DdnsStore
def get_settings(request: Request) -> DdnsSettings:
return request.app.state.settings
def get_store(request: Request) -> DdnsStore:
return request.app.state.store
def get_service(request: Request) -> DdnsService:
return request.app.state.service
SettingsDep = Annotated[DdnsSettings, Depends(get_settings)]
StoreDep = Annotated[DdnsStore, Depends(get_store)]
ServiceDep = Annotated[DdnsService, Depends(get_service)]
def authenticate_update(
authorization: Annotated[str | None, Header()] = None,
username: str | None = Query(default=None),
password: str | None = Query(default=None),
pass_: str | None = Query(default=None, alias="pass"),
passwd: str | None = Query(default=None),
) -> tuple[str | None, str | None]:
supplied_user = username
supplied_token = password or pass_ or passwd
if authorization and authorization.lower().startswith("basic "):
supplied_user, supplied_token = credentials_from_basic_auth(authorization)
return supplied_user, supplied_token
def authenticate_admin(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> None:
settings = get_settings(request)
if not settings.admin_password:
raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required")
supplied = None
if authorization and authorization.lower().startswith("basic "):
_, supplied = credentials_from_basic_auth(authorization)
if not supplied or not secrets.compare_digest(supplied, settings.admin_password):
raise HTTPException(
status_code=401,
detail="admin authentication required",
headers={"WWW-Authenticate": 'Basic realm="karldns"'},
)
def apply_update_response(
service: DdnsService,
request: Request,
hostname: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> str | PlainTextResponse:
try:
return service.apply_update(request, hostname, myip, ipaddr, myipv6, ip6addr).result.dyndns_response
except BadIpError:
return PlainTextResponse("badip", status_code=400)
except DnsProviderError:
return PlainTextResponse("911 dns publish failed", status_code=500)
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from .base import (
DnsConfigurationError,
DnsProviderError,
delete_dns_records,
dns_backend_configured,
publish_dns,
validate_dns_provider_settings,
)
from .cloudflare import delete_cloudflare_records, publish_cloudflare
from .rfc2136 import delete_rfc2136_records, publish_rfc2136
__all__ = [
"DnsConfigurationError",
"DnsProviderError",
"delete_cloudflare_records",
"delete_dns_records",
"delete_rfc2136_records",
"dns_backend_configured",
"publish_cloudflare",
"publish_dns",
"publish_rfc2136",
"validate_dns_provider_settings",
]
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings, UpdateResult
SUPPORTED_DNS_PROVIDERS = {"", "cloudflare", "rfc2136"}
class DnsProviderError(RuntimeError):
"""Raised for expected DNS provider publish/delete failures."""
class DnsConfigurationError(RuntimeError):
"""Raised when DNS provider settings are invalid at startup."""
def publish_dns(result: UpdateResult, settings: DdnsSettings) -> None:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
if provider == "cloudflare":
from .cloudflare import publish_cloudflare
publish_cloudflare(result, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
from .rfc2136 import publish_rfc2136
publish_rfc2136(result, settings)
return
if settings.require_dns_provider:
raise DnsProviderError("DNS provider is required but not configured")
def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
if provider == "cloudflare":
from .cloudflare import delete_cloudflare_records
delete_cloudflare_records(hostname, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
from .rfc2136 import delete_rfc2136_records
delete_rfc2136_records(hostname, settings)
return
if settings.require_dns_provider:
raise DnsProviderError("DNS provider is required but not configured")
def dns_backend_configured(settings: DdnsSettings) -> bool:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
def validate_dns_provider_settings(settings: DdnsSettings) -> None:
provider = _normalized_provider(settings)
if provider not in SUPPORTED_DNS_PROVIDERS:
raise DnsConfigurationError(f"Unsupported DDNS_DNS_PROVIDER: {settings.dns_provider}")
if not settings.require_dns_provider:
return
if provider == "cloudflare":
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsConfigurationError(
"DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
)
return
if provider == "rfc2136" or settings.rfc2136_server:
if not settings.rfc2136_server or not settings.rfc2136_zone:
raise DnsConfigurationError(
"DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
)
return
raise DnsConfigurationError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
def _normalized_provider(settings: DdnsSettings) -> str:
return settings.dns_provider.strip().lower()
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from ..ddns_models import DdnsSettings, UpdateResult
from .base import DnsProviderError
def publish_cloudflare(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsProviderError("Cloudflare DNS provider is missing required credentials")
if result.update_ipv4:
_cloudflare_upsert_record(settings, result.hostname, "A", result.ipv4)
if result.update_ipv6:
_cloudflare_upsert_record(settings, result.hostname, "AAAA", result.ipv6)
def delete_cloudflare_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsProviderError("Cloudflare DNS provider is missing required credentials")
for record_type in ("A", "AAAA"):
record = _cloudflare_find_record(settings, hostname, record_type)
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
def _cloudflare_upsert_record(settings: DdnsSettings, hostname: str, record_type: str, value: str | None) -> None:
record = _cloudflare_find_record(settings, hostname, record_type)
if value is None:
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
return
payload = {
"type": record_type,
"name": hostname,
"content": value,
"ttl": settings.ttl,
"proxied": False,
}
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="PATCH", payload=payload)
else:
_cloudflare_request(settings, "/dns_records", method="POST", payload=payload)
def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, str] | None:
query = urllib.parse.urlencode({"type": record_type, "name": hostname})
response = _cloudflare_request(settings, f"/dns_records?{query}")
records = response.get("result", [])
return records[0] if records else None
def _cloudflare_request(
settings: DdnsSettings,
path: str,
method: str = "GET",
payload: dict[str, object] | None = None,
) -> dict[str, object]:
url = f"https://api.cloudflare.com/client/v4/zones/{settings.cloudflare_zone_id}{path}"
body = json.dumps(payload).encode("utf-8") if payload is not None else None
request = urllib.request.Request(url, data=body, method=method)
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=10) as response:
parsed = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise DnsProviderError("Cloudflare API request failed") from exc
except json.JSONDecodeError as exc:
raise DnsProviderError("Cloudflare API returned invalid JSON") from exc
if not parsed.get("success", False):
raise DnsProviderError("Cloudflare API returned an unsuccessful response")
return parsed
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings, UpdateResult
from .base import DnsProviderError
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.exception
import dns.query
import dns.rcode
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(result.hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = result.hostname.removesuffix("." + zone).rstrip(".") or "@"
if result.update_ipv4 and result.ipv4:
update.replace(relative, settings.ttl, "A", result.ipv4)
elif result.update_ipv4:
update.delete(relative, "A")
if result.update_ipv6 and result.ipv6:
update.replace(relative, settings.ttl, "AAAA", result.ipv6)
elif result.update_ipv6:
update.delete(relative, "AAAA")
try:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
except dns.exception.DNSException as exc:
raise DnsProviderError("RFC2136 DNS update failed") from exc
rcode = response.rcode()
if rcode != 0:
raise DnsProviderError(f"RFC2136 update failed: {dns.rcode.to_text(rcode)}")
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.exception
import dns.query
import dns.rcode
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = hostname.removesuffix("." + zone).rstrip(".") or "@"
update.delete(relative, "A")
update.delete(relative, "AAAA")
try:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
except dns.exception.DNSException as exc:
raise DnsProviderError("RFC2136 DNS delete failed") from exc
rcode = response.rcode()
if rcode != 0:
raise DnsProviderError(f"RFC2136 delete failed: {dns.rcode.to_text(rcode)}")
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
DNS_DELETE_FAILED = "dns_delete_failed"
DNS_FAILED = "dns_failed"
UPDATED = "updated"
UNCHANGED = "unchanged"
DASHBOARD_PASSWORD_CHANGED = "dashboard_password_changed"
DASHBOARD_PASSWORD_ENABLED = "dashboard_password_enabled"
DASHBOARD_PASSWORD_REMOVED = "dashboard_password_removed"
DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated"
DASHBOARD_ROUTER_CREDENTIALS_ROTATED = "dashboard_router_credentials_rotated"
DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted"
DASHBOARD_DOMAIN_CLAIM_CREATED = "dashboard_domain_claim_created"
DASHBOARD_DOMAIN_CLAIM_VERIFIED = "dashboard_domain_claim_verified"
DASHBOARD_DOMAIN_CLAIM_PENDING = "dashboard_domain_claim_pending"
DASHBOARD_CUSTOM_HOSTNAME_CREATED = "dashboard_custom_hostname_created"
ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted"
ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated"
ADMIN_MANAGEMENT_URL_ROTATED = "admin_management_url_rotated"
ADMIN_ROUTER_CREDENTIALS_ROTATED = "admin_router_credentials_rotated"
View File
Whitespace-only changes.
+235
View File
@@ -0,0 +1,235 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_security import client_ip, require_admin_csrf
from ..ddns_service import normalize_hostname
from ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
from ..event_statuses import (
ADMIN_HOSTNAME_DELETED,
ADMIN_MANAGEMENT_URL_ROTATED,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
ADMIN_UPDATE_URL_ROTATED,
)
from ..web.forms import first_form_value, request_form
from ..web.templates import _copy_row
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100
def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter()
records_path = f"{admin_path}/records"
events_path = f"{admin_path}/events"
def render_admin(
service: ServiceDep,
settings: SettingsDep,
store: StoreDep,
table_offsets: dict[str, int] | None = None,
notice: str | None = None,
) -> str:
table_offsets = table_offsets or {}
return _render_admin_page(
service,
settings,
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
store.list_domain_challenges(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("domain_challenges", 0)),
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
store.list_scheduled_tasks(),
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
{
"accounts": store.count_accounts(),
"domain_challenges": store.count_domain_challenges(),
"cleanup_runs": store.count_cleanup_runs(),
"update_events": store.count_update_events(),
},
table_offsets,
ADMIN_TABLE_LIMIT,
notice,
)
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
def admin(
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
accounts_offset: int = Query(0, ge=0),
domain_claims_offset: int = Query(0, ge=0),
cleanup_runs_offset: int = Query(0, ge=0),
update_events_offset: int = Query(0, ge=0),
) -> str:
return render_admin(
service,
settings,
store,
{
"accounts": accounts_offset,
"domain_challenges": domain_claims_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
},
)
@router.post(f"{admin_path}/accounts/delete", include_in_schema=False)
async def admin_delete_account(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
hostname = normalize_hostname(first_form_value(form, "hostname"), settings, expand_suffix=False)
if hostname:
try:
await run_in_threadpool(service.delete_account, hostname, "admin")
except DnsProviderError:
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_HOSTNAME_DELETED,
"hostname deleted by admin",
client_ip(request, settings),
)
return RedirectResponse(admin_path, status_code=303)
@router.post(f"{admin_path}/accounts/rotate", include_in_schema=False)
async def admin_rotate_account(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
hostname = normalize_hostname(first_form_value(form, "hostname"), settings, expand_suffix=False)
action = first_form_value(form, "action")
account = await run_in_threadpool(store.get_account_by_hostname, hostname)
if not account:
raise HTTPException(status_code=404, detail="hostname not found")
notice = None
if action == "update":
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_UPDATE_URL_ROTATED,
"router update URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
elif action == "management":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_MANAGEMENT_URL_ROTATED,
"management URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Private status page rotated", [_copy_row("Private status page:", service.magic_management_url(rotated))]) if rotated else None
elif action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
"router credentials rotated by admin",
client_ip(request, settings),
)
if rotated:
notice = _admin_secret_panel(
"Router password rotated",
[
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
_copy_row("One-box update URL:", service.credentialed_update_url(rotated), "Secret"),
_copy_row("Benutzername (optional):", str(rotated["username"]), "Public"),
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
],
)
else:
raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
return HTMLResponse(page)
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
async def admin_run_cleanup(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
result = await run_in_threadpool(
store.cleanup,
settings.cleanup_challenge_hours,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
)
notice = _admin_secret_panel(
"Cleanup completed",
[
_copy_row("Domain claims removed", str(result["domain_challenges"])),
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
_copy_row("Old update events removed", str(result["update_events"])),
],
)
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
return HTMLResponse(page)
@router.get(records_path, include_in_schema=False)
def records(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict[str, object]:
return {
"items": store.list_records_page(limit=limit, offset=offset),
"limit": limit,
"offset": offset,
"total": store.count_records(),
}
@router.get(events_path, include_in_schema=False)
def events(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict[str, object]:
return {
"items": store.list_update_events_page(limit=limit, offset=offset),
"limit": limit,
"offset": offset,
"total": store.count_update_events(),
}
if admin_path == "/admin":
router.add_api_route("/records", records, methods=["GET"], include_in_schema=False)
router.add_api_route("/events", events, methods=["GET"], include_in_schema=False)
return router
router = make_router()
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import sqlite3
from fastapi import APIRouter, HTTPException, Request, Response, status
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_service import BadIpError, normalize_domain
from ..dependencies import ServiceDep, StoreDep
from ..schemas import (
CustomHostnameRequest,
DomainChallengeRequest,
DomainChallengeResponse,
ErrorResponse,
HostnameCredentials,
MagicHostnameRequest,
ManagedRecord,
RouterUpdateResponse,
VerifyDomainRequest,
VerifyDomainResponse,
_credentials_response,
_managed_record,
)
router = APIRouter(prefix="/api/v1")
@router.post(
"/hostnames/magic",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate a provider-owned DynDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_magic_hostname(payload: MagicHostnameRequest, service: ServiceDep) -> HostnameCredentials:
account = await run_in_threadpool(service.create_managed_account, payload.username)
return _credentials_response(service, account)
@router.get(
"/management/{management_slug}",
response_model=ManagedRecord,
summary="Read a hostname by magic management link",
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
tags=["management"],
responses={404: {"model": ErrorResponse}},
)
async def read_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> ManagedRecord:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return _managed_record(service, account)
@router.delete(
"/management/{management_slug}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by magic management link",
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
tags=["management"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "api")
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS delete failed; hostname was not deleted") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/domains/challenges",
response_model=DomainChallengeResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a DNS TXT domain verification challenge",
tags=["domains"],
responses={400: {"model": ErrorResponse}},
)
async def create_domain_challenge(payload: DomainChallengeRequest, service: ServiceDep) -> DomainChallengeResponse:
domain = normalize_domain(payload.domain)
try:
challenge = await run_in_threadpool(service.create_domain_challenge, domain)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
return DomainChallengeResponse(
domain=domain,
txt_name=service.verification_name(domain),
txt_value=challenge["token"],
claim_secret=challenge["claim_secret"],
)
@router.post(
"/domains/verify",
response_model=VerifyDomainResponse,
summary="Verify a domain TXT challenge",
tags=["domains"],
responses={404: {"model": ErrorResponse}},
)
async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> VerifyDomainResponse:
domain = normalize_domain(payload.domain)
found, _challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret)
if not found:
return VerifyDomainResponse(
domain=domain,
verified=False,
message="TXT record not found yet",
claim_secret=payload.claim_secret,
)
return VerifyDomainResponse(
domain=domain,
verified=True,
message="domain verified",
claim_secret=payload.claim_secret,
)
@router.post(
"/hostnames/custom",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate credentials for a verified custom hostname",
tags=["hostnames"],
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def create_custom_hostname(payload: CustomHostnameRequest, service: ServiceDep) -> HostnameCredentials:
account = await run_in_threadpool(
service.create_custom_account,
payload.hostname,
payload.claim_secret,
payload.username,
)
return _credentials_response(service, account)
@router.get(
"/updates/{update_slug}",
response_model=RouterUpdateResponse,
summary="Router-compatible update endpoint",
tags=["updates"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def update_hostname(
update_slug: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RouterUpdateResponse:
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
outcome = await run_in_threadpool(
service.apply_update,
request,
hostname,
myip,
ipaddr,
myipv6,
ip6addr,
)
except BadIpError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
result = outcome.result
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse
from ..web.styles import APP_CSS
ASSET_DIR = Path(__file__).parents[1] / "assets"
router = APIRouter(include_in_schema=False)
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"}
APP_JS = """
(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const storageKey = "karldns-theme";
const systemTheme = () => (media.matches ? "dark" : "light");
const storedTheme = () => {
try {
return window.localStorage.getItem(storageKey);
} catch (_) {
return null;
}
};
const setStoredTheme = (theme) => {
try {
window.localStorage.setItem(storageKey, theme);
} catch (_) {}
};
const syncToggle = (theme) => {
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.textContent = theme === "dark" ? "Dark" : "Light";
button.setAttribute("aria-label", `Switch to ${theme === "dark" ? "light" : "dark"} theme`);
});
};
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
const theme = requestedTheme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
syncToggle(theme);
return theme;
};
applyTheme();
media.addEventListener("change", () => {
if (!storedTheme()) applyTheme();
});
document.addEventListener("DOMContentLoaded", () => syncToggle(document.documentElement.getAttribute("data-bs-theme") || "light"));
document.addEventListener("click", (event) => {
const toggle = event.target.closest("[data-theme-toggle]");
if (!toggle) return;
const currentTheme = document.documentElement.getAttribute("data-bs-theme") || systemTheme();
const nextTheme = currentTheme === "dark" ? "light" : "dark";
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
})();
document.addEventListener("click", async (event) => {
const toggle = event.target.closest("[data-secret-toggle]");
if (toggle) {
const group = toggle.closest(".copy-field");
const field = group ? group.querySelector("[data-secret-input], [data-secret-value]") : null;
if (!field) return;
if (field.matches("[data-secret-input]")) {
const masked = field.type === "password";
field.type = masked ? "text" : "password";
toggle.textContent = masked ? "Hide" : "View";
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
} else {
const masked = field.dataset.secretMasked !== "false";
field.dataset.secretMasked = masked ? "false" : "true";
field.textContent = masked ? (field.dataset.secretValue || "") : "********";
toggle.textContent = masked ? "Hide" : "View";
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
}
return;
}
const button = event.target.closest("[data-copy]");
if (!button) return;
try {
await navigator.clipboard.writeText(button.dataset.copy || "");
const old = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = old;
}, 1200);
} catch (_) {}
});
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
const table = tableShell.querySelector("table");
const tbody = tableShell.querySelector("tbody");
const search = tableShell.querySelector("[data-table-search]");
const pageSize = tableShell.querySelector("[data-table-page-size]");
const count = tableShell.querySelector("[data-table-count]");
const pagination = tableShell.querySelector("[data-table-pagination]");
if (!table || !tbody || !search || !pageSize || !count || !pagination) return;
const headerCount = table.querySelectorAll("thead th").length || 1;
const originalRows = Array.from(tbody.querySelectorAll("tr"));
const dataRows = originalRows.filter((row) => !row.classList.contains("empty-row"));
let currentPage = 1;
const emptyRow = (message) => {
const row = document.createElement("tr");
row.className = "empty-row";
const cell = document.createElement("td");
cell.className = "empty";
cell.colSpan = headerCount;
cell.textContent = message;
row.append(cell);
return row;
};
const visibleRows = () => {
const query = search.value.trim().toLowerCase();
if (!query) return dataRows;
return dataRows.filter((row) => row.textContent.toLowerCase().includes(query));
};
const renderButton = (label, page, disabled = false, active = false) => {
const item = document.createElement("li");
item.className = `page-item${disabled ? " disabled" : ""}${active ? " active" : ""}`;
const button = document.createElement("button");
button.type = "button";
button.className = "page-link";
button.textContent = label;
button.disabled = disabled;
button.addEventListener("click", () => {
currentPage = page;
render();
});
item.append(button);
return item;
};
const renderPagination = (totalPages) => {
pagination.replaceChildren();
if (totalPages <= 1) return;
pagination.append(renderButton("Previous", Math.max(1, currentPage - 1), currentPage === 1));
for (let page = 1; page <= totalPages; page += 1) {
if (totalPages > 7 && page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) {
if (page === 2 || page === totalPages - 1) pagination.append(renderButton("...", currentPage, true));
continue;
}
pagination.append(renderButton(String(page), page, false, page === currentPage));
}
pagination.append(renderButton("Next", Math.min(totalPages, currentPage + 1), currentPage === totalPages));
};
const render = () => {
const rows = visibleRows();
const size = Number.parseInt(pageSize.value, 10) || 25;
const totalPages = Math.max(1, Math.ceil(rows.length / size));
currentPage = Math.min(currentPage, totalPages);
const start = (currentPage - 1) * size;
const pageRows = rows.slice(start, start + size);
tbody.replaceChildren(...(pageRows.length ? pageRows : [emptyRow(dataRows.length ? "No matching rows." : "No rows yet.")]));
const shownStart = rows.length ? start + 1 : 0;
const shownEnd = rows.length ? Math.min(start + size, rows.length) : 0;
count.textContent = `${shownStart}-${shownEnd} of ${rows.length}`;
renderPagination(totalPages);
};
search.addEventListener("input", () => {
currentPage = 1;
render();
});
pageSize.addEventListener("change", () => {
currentPage = 1;
render();
});
render();
});
""".strip()
@router.get("/favicon.ico")
def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-16x16.png")
def favicon_16_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-32x32.png")
def favicon_32_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/apple-touch-icon.png")
def apple_touch_icon_png() -> FileResponse:
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.png")
def logo_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-192.png")
def logo_192_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-512.png")
def logo_512_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/site.webmanifest")
def site_webmanifest() -> Response:
return Response(
"""
{
"name": "KarlDNS",
"short_name": "KarlDNS",
"description": "Accountless Dynamic DNS for routers and home labs.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"}
]
}
""".strip(),
media_type="application/manifest+json",
headers=ASSET_CACHE_HEADERS,
)
@router.get("/app.js")
def app_js() -> Response:
return Response(APP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/app.css")
def app_css() -> Response:
return Response(APP_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/install.sh")
def install_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
status_code=302,
)
@router.get("/uninstall.sh")
def uninstall_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
status_code=302,
)
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from typing import NoReturn
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import (
DASHBOARD_HOSTNAME_DELETED,
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
DASHBOARD_UPDATE_URL_ROTATED,
)
from ..web.forms import first_form_value, request_form
from ..web.html_utils import trusted
from ..web.templates import _page
from ..web.templates_management import _render_management_page, _render_management_unlock_page
from . import management_domains, management_passwords
from .management_shared import (
get_active_management_account,
get_active_management_account_async,
has_management_access,
locked_management_response,
render_management_page_async,
)
router = APIRouter()
router.include_router(management_passwords.router)
router.include_router(management_domains.router)
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
def manage_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account)
if not has_management_access(request, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token)
return _render_management_page(service, settings, management_slug, account, csrf_token)
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
async def rotate_magic_credentials(
management_slug: str,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
action = first_form_value(form, "action")
if action == "update":
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
if not rotated:
return _not_found()
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_UPDATE_URL_ROTATED,
"router update URL rotated",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Update-URL rotated.",
)
if action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
if not rotated:
return _not_found()
rotated["management_slug"] = management_slug
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
"router credentials rotated",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Router credentials rotated. Copy the new password now.",
created_account=rotated,
)
raise _bad_rotation_action()
@router.post("/m/{management_slug}/delete", include_in_schema=False)
async def delete_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "management-link")
except DnsProviderError:
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_HOSTNAME_DELETED,
"hostname deleted from dashboard",
client_ip(request, settings),
)
return HTMLResponse(
_page(
"DynDNS deleted",
trusted(
"""
<main>
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Deleted</p>
<h1>Hostname deleted.</h1>
<p class="lead">The update URL no longer works.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
</section>
</main>
"""
),
)
)
def _not_found() -> NoReturn:
raise HTTPException(status_code=404, detail="not found")
def _bad_rotation_action() -> NoReturn:
raise HTTPException(status_code=400, detail="invalid rotation action")
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..ddns_service import normalize_domain
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import (
DASHBOARD_CUSTOM_HOSTNAME_CREATED,
DASHBOARD_DOMAIN_CLAIM_CREATED,
DASHBOARD_DOMAIN_CLAIM_PENDING,
DASHBOARD_DOMAIN_CLAIM_VERIFIED,
)
from ..web.forms import first_form_value, request_form
from ..web.templates_public import _render_public_home
from .management_shared import (
get_active_management_account_async,
has_management_access,
locked_management_response,
render_management_page_async,
)
router = APIRouter()
@router.get("/d/{claim_secret}", response_class=HTMLResponse, include_in_schema=False)
async def manage_domain_claim(claim_secret: str, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
challenge = await run_in_threadpool(store.get_domain_challenge_by_secret, claim_secret)
if not challenge:
raise HTTPException(status_code=404, detail="not found")
return await run_in_threadpool(_render_public_home, service, settings, None, None, challenge)
@router.post("/m/{management_slug}/domains/request", response_class=HTMLResponse, include_in_schema=False)
async def request_dashboard_domain(
management_slug: str,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
try:
challenge = await run_in_threadpool(
service.create_domain_challenge,
first_form_value(form, "domain"),
str(account["hostname"]),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
await run_in_threadpool(
store.log_update_event,
str(account["hostname"]),
None,
None,
DASHBOARD_DOMAIN_CLAIM_CREATED,
"domain claim created",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Domain claim created. Add the TXT record, then verify it.",
challenge=challenge,
)
@router.post("/m/{management_slug}/domains/verify", response_class=HTMLResponse, include_in_schema=False)
async def verify_dashboard_domain(
management_slug: str,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
domain = normalize_domain(first_form_value(form, "domain"))
claim_secret = first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, str(account["hostname"]))
message = "Domain verified. You can now create a custom hostname." if found else "TXT record not found yet. DNS propagation can take a few minutes."
await run_in_threadpool(
store.log_update_event,
str(account["hostname"]),
None,
None,
DASHBOARD_DOMAIN_CLAIM_VERIFIED if found else DASHBOARD_DOMAIN_CLAIM_PENDING,
"domain claim verified" if found else "domain claim TXT not found",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message=message,
challenge=challenge,
)
@router.post("/m/{management_slug}/domains/hostnames", response_class=HTMLResponse, include_in_schema=False)
async def create_dashboard_custom_hostname(
management_slug: str,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
created = await run_in_threadpool(
service.create_custom_account,
first_form_value(form, "hostname"),
first_form_value(form, "claim_secret"),
None,
str(account["hostname"]),
)
await run_in_threadpool(
store.log_update_event,
str(account["hostname"]),
None,
None,
DASHBOARD_CUSTOM_HOSTNAME_CREATED,
"custom hostname created",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Custom hostname created. Copy these router settings now.",
created_account=created,
)
@@ -0,0 +1,146 @@
from __future__ import annotations
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
from ..web.forms import first_form_value, request_form
from ..web.templates_management import _render_management_unlock_page
from .management_shared import (
delete_unlock_cookie,
get_active_management_account_async,
has_management_access,
management_error_response_async,
render_management_page_async,
set_unlock_cookie,
unlock_cookie_seconds,
)
router = APIRouter()
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not account.get("dashboard_password_hash"):
return RedirectResponse(f"/m/{management_slug}", status_code=303)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
password = first_form_value(form, "dashboard_password")
hostname = str(account["hostname"])
is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password)
if not is_valid:
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect dashboard password."),
status_code=401,
)
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
set_unlock_cookie(
response,
settings,
management_slug,
account,
unlock_cookie_seconds(settings, first_form_value(form, "stay_unlocked")),
)
return response
@router.post("/m/{management_slug}/password", include_in_schema=False)
async def set_dashboard_password(
management_slug: str,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if account.get("dashboard_password_hash"):
is_current = await run_in_threadpool(
store.verify_dashboard_password,
hostname,
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
password = first_form_value(form, "dashboard_password")
confirm = first_form_value(form, "dashboard_password_confirm")
if len(password) < 8:
page = await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Dashboard password must be at least 8 characters.",
)
return HTMLResponse(page, status_code=400)
if password != confirm:
page = await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Dashboard password confirmation did not match.",
)
return HTMLResponse(page, status_code=400)
had_password = bool(account.get("dashboard_password_hash"))
await run_in_threadpool(store.set_dashboard_password, hostname, password)
account = await get_active_management_account_async(store, management_slug)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_PASSWORD_CHANGED if had_password else DASHBOARD_PASSWORD_ENABLED,
"dashboard password changed" if had_password else "dashboard password enabled",
client_ip(request, settings),
)
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
set_unlock_cookie(response, settings, management_slug, account, settings.dashboard_unlock_seconds)
return response
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
async def remove_dashboard_password(
management_slug: str,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if account.get("dashboard_password_hash"):
is_current = await run_in_threadpool(
store.verify_dashboard_password,
hostname,
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
await run_in_threadpool(store.clear_dashboard_password, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_PASSWORD_REMOVED,
"dashboard password removed",
client_ip(request, settings),
)
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
delete_unlock_cookie(response, management_slug)
return response
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from fastapi import HTTPException, Request, Response
from fastapi.responses import HTMLResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_models import DdnsSettings
from ..ddns_security import (
DASHBOARD_UNLOCK_COOKIE,
dashboard_csrf_token,
sign_dashboard_unlock_cookie,
verify_dashboard_unlock_cookie,
)
from ..ddns_service import DdnsService
from ..ddns_store import DdnsStore
from ..store.types import AccountRow
from ..web.templates_management import _render_management_page, _render_management_unlock_page
ManagementAccount = AccountRow
def get_active_management_account(store: DdnsStore, management_slug: str) -> ManagementAccount:
account = store.get_account_by_management_slug(management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return account
async def get_active_management_account_async(store: DdnsStore, management_slug: str) -> ManagementAccount:
return await run_in_threadpool(get_active_management_account, store, management_slug)
def has_management_access(request: Request, management_slug: str, account: ManagementAccount) -> bool:
password_hash = account.get("dashboard_password_hash")
if not password_hash:
return True
return verify_dashboard_unlock_cookie(
str(account["hostname"]),
management_slug,
str(password_hash),
request.cookies.get(DASHBOARD_UNLOCK_COOKIE),
)
def locked_management_response(management_slug: str, account: ManagementAccount) -> HTMLResponse:
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
def management_error_response(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
) -> HTMLResponse:
return HTMLResponse(
_render_management_page(service, settings, management_slug, account, dashboard_csrf_token(management_slug, account), message=message),
status_code=400,
)
async def management_error_response_async(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
) -> HTMLResponse:
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message)
async def render_management_page_async(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
csrf_token: str,
message: str | None = None,
challenge: dict[str, str | None] | None = None,
created_account: dict[str, str] | None = None,
) -> str:
return await run_in_threadpool(
_render_management_page,
service,
settings,
management_slug,
account,
csrf_token,
message,
challenge,
created_account,
)
def set_unlock_cookie(
response: Response,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
max_age_seconds: int,
) -> None:
password_hash = account.get("dashboard_password_hash")
if not password_hash:
return
response.set_cookie(
DASHBOARD_UNLOCK_COOKIE,
sign_dashboard_unlock_cookie(str(account["hostname"]), management_slug, str(password_hash), max_age_seconds),
max_age=max_age_seconds,
path=f"/m/{management_slug}",
httponly=True,
samesite="lax",
secure=settings.public_base_url.startswith("https://"),
)
def delete_unlock_cookie(response: Response, management_slug: str) -> None:
response.delete_cookie(DASHBOARD_UNLOCK_COOKIE, path=f"/m/{management_slug}", httponly=True, samesite="lax")
def unlock_cookie_seconds(settings: DdnsSettings, stay_unlocked: str) -> int:
if stay_unlocked:
return settings.dashboard_stay_unlocked_seconds
return settings.dashboard_unlock_seconds
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip
from ..ddns_service import normalize_domain
from ..dependencies import ServiceDep, SettingsDep
from ..web.forms import first_form_value, request_form
from ..web.templates_public import _render_privacy_page, _render_public_home
router = APIRouter()
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
def index(settings: SettingsDep, service: ServiceDep) -> str:
return _render_public_home(service, settings, None, None)
@router.get("/magic", include_in_schema=False)
def magic_form() -> RedirectResponse:
return RedirectResponse("/#create", status_code=303)
@router.post("/magic", response_class=HTMLResponse, include_in_schema=False)
async def magic_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
await request_form(request, settings)
account = await run_in_threadpool(service.create_managed_account, None)
return _render_public_home(service, settings, account, None)
@router.post("/request-domain", response_class=HTMLResponse, include_in_schema=False)
async def request_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
form = await request_form(request, settings)
try:
challenge = await run_in_threadpool(
service.create_domain_challenge,
first_form_value(form, "domain"),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
return _render_public_home(service, settings, None, None, challenge)
@router.post("/verify-domain", response_class=HTMLResponse, include_in_schema=False)
async def verify_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
form = await request_form(request, settings)
domain = normalize_domain(first_form_value(form, "domain"))
claim_secret = first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
if not found:
message = "TXT record not found yet. DNS propagation can take a few minutes."
return _render_public_home(service, settings, None, message, challenge)
return _render_public_home(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge)
@router.post("/accounts", response_class=HTMLResponse, include_in_schema=False)
async def self_service_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
form = await request_form(request, settings)
mode = first_form_value(form, "mode")
if mode == "managed":
account = await run_in_threadpool(service.create_managed_account, None)
else:
claim_secret = first_form_value(form, "claim_secret")
account = await run_in_threadpool(
service.create_custom_account,
first_form_value(form, "hostname"),
claim_secret,
None,
)
return _render_public_home(service, settings, account, None)
@router.get("/healthz", response_class=PlainTextResponse)
def healthz() -> str:
return "ok"
@router.get("/ip", response_class=PlainTextResponse)
def visible_ip(request: Request, settings: SettingsDep) -> str:
return client_ip(request, settings)
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
def privacy() -> str:
return _render_privacy_page()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update
router = APIRouter()
@router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
def slug_update(
update_slug: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
account = store.get_account_by_slug(update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return apply_update_response(service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr)
@router.get("/nic/update", response_class=PlainTextResponse, response_model=None)
def update(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
credentials: tuple[str | None, str | None] = Depends(authenticate_update),
hostname: str | None = Query(default=None),
domain: str | None = Query(default=None),
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
host = (hostname or domain or "").strip().lower().rstrip(".")
if not host:
return PlainTextResponse("nohost", status_code=400)
if settings.allowed_hosts and host not in settings.allowed_hosts:
return PlainTextResponse("nohost", status_code=403)
supplied_user, supplied_token = credentials
account_ok = store.verify_account(host, supplied_user, supplied_token)
global_secret_ok = (
bool(settings.allowed_hosts)
and host in settings.allowed_hosts
and bool(settings.shared_secret)
and bool(supplied_token)
and secrets.compare_digest(supplied_token, settings.shared_secret)
)
if not global_secret_ok and not account_ok:
return PlainTextResponse("badauth", status_code=401)
return apply_update_response(service, request, host, myip, ipaddr, myipv6, ip6addr)
+97
View File
@@ -0,0 +1,97 @@
from pydantic import BaseModel, ConfigDict, Field
from .ddns_service import DdnsService
class ErrorResponse(BaseModel):
detail: str = Field(examples=["not found"])
class MagicHostnameRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class HostnameCredentials(BaseModel):
hostname: str
username: str
password: str = Field(description="Secret router password. Store it now; it is not recoverable later.")
update_url: str = Field(description="Secret bearer update URL for router DynDNS updates.")
credentialed_update_url: str = Field(
description="Secret update URL with router credentials embedded in the query string."
)
management_url: str = Field(description="Secret bearer management URL for reading or deleting this hostname.")
class ManagedRecord(BaseModel):
hostname: str
username: str
update_url: str = Field(description="Secret bearer update URL for router DynDNS updates.")
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
class DomainChallengeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
domain: str = Field(examples=["example.net"])
class DomainChallengeResponse(BaseModel):
domain: str
txt_name: str
txt_value: str
claim_secret: str = Field(description="Secret bearer token for returning to and verifying this domain claim.")
class VerifyDomainRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
domain: str = Field(examples=["example.net"])
claim_secret: str
class VerifyDomainResponse(BaseModel):
domain: str
verified: bool
message: str
claim_secret: str = Field(description="Secret bearer token for continuing this domain claim flow.")
class CustomHostnameRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
hostname: str = Field(examples=["home.example.net"])
claim_secret: str
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class RouterUpdateResponse(BaseModel):
status: str = Field(examples=["good"])
hostname: str
ip: str
def _credentials_response(service: DdnsService, account: dict[str, str]) -> HostnameCredentials:
return HostnameCredentials(
hostname=account["hostname"],
username=account["username"],
password=account["password"],
update_url=service.fritz_update_url(account),
credentialed_update_url=service.credentialed_update_url(account),
management_url=service.magic_management_url(account),
)
def _managed_record(service: DdnsService, account: dict[str, str | int | None]) -> ManagedRecord:
account_for_url = {"update_slug": str(account["update_slug"])}
return ManagedRecord(
hostname=str(account["hostname"]),
username=str(account["username"]),
update_url=service.fritz_update_url(account_for_url),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import threading
from contextlib import contextmanager
from pathlib import Path
from sqlite3 import Connection
from time import monotonic
from .accounts import AccountsMixin
from .domains import DomainsMixin
from .rate_limit import RateLimitCleanupMixin
from .records import RecordsEventsMixin
from .scheduler import SchedulerMixin
from .schema import connect_database, initialize_schema
class DdnsStore(AccountsMixin, DomainsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self._connections = threading.local()
self._connection_lock = threading.Lock()
self._tracked_connections: list[Connection] = []
self._closed_connection_ids: set[int] = set()
self._rate_limit_prune_lock = threading.Lock()
self._rate_limit_lock = threading.Lock()
self._rate_limit_buckets: dict[str, dict[str, float | int]] = {}
self._rate_limit_dirty_counts: dict[tuple[str, int], int] = {}
self._last_rate_limit_flush = monotonic()
self._last_rate_limit_prune_window = -1
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
@contextmanager
def connect(self):
conn = self._thread_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def _init_db(self) -> None:
with self.connect() as conn:
initialize_schema(conn)
def optimize(self) -> None:
with self.connect() as conn:
conn.execute("PRAGMA optimize")
def checkpoint_wal(self) -> None:
if str(self.database_path) == ":memory:":
return
with self.connect() as conn:
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
def _thread_connection(self) -> Connection:
conn = getattr(self._connections, "conn", None)
if conn is None or id(conn) in self._closed_connection_ids:
conn = connect_database(self.database_path)
self._connections.conn = conn
with self._connection_lock:
self._tracked_connections.append(conn)
self._closed_connection_ids.discard(id(conn))
return conn
def close_connections(self) -> None:
with self._connection_lock:
connections = list(self._tracked_connections)
self._tracked_connections.clear()
self._closed_connection_ids.update(id(conn) for conn in connections)
for conn in connections:
conn.close()
if getattr(self._connections, "conn", None) in connections:
self._connections.conn = None
close = close_connections
__all__ = ["DdnsStore"]
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import re
import secrets
import sqlite3
from datetime import UTC, datetime
from ..ddns_security import hash_lookup_token, hash_secret, verify_secret
from .types import AccountRow
class AccountsMixin:
def create_account(self, hostname: str, username: str | None = None) -> AccountRow:
now = datetime.now(UTC).isoformat()
token = secrets.token_urlsafe(24)
update_slug = secrets.token_urlsafe(32)
management_slug = secrets.token_urlsafe(32)
username = username or _default_username(hostname)
token_hash = hash_secret(token)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute(
"""
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
VALUES (?, ?, ?, ?, ?, ?, 0)
""",
(hostname, username, token_hash, update_slug, management_hash, now),
)
return {
"hostname": hostname,
"username": username,
"password": token,
"update_slug": update_slug,
"management_slug": management_slug,
}
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_accounts(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()
return int(row[0])
def get_account_by_hostname(self, hostname: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
""",
(hostname,),
).fetchone()
return dict(account) if account else None
def get_account_by_slug(self, update_slug: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
WHERE update_slug = ?
""",
(update_slug,),
).fetchone()
return dict(account) if account else None
def get_account_by_management_slug(self, management_slug: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ?
""",
(hash_lookup_token(management_slug),),
).fetchone()
return dict(account) if account else None
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool:
if not username or not token:
return False
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT token_hash, disabled FROM accounts
WHERE hostname = ? AND username = ?
""",
(hostname, username),
).fetchone()
if not account or account["disabled"]:
return False
return verify_secret(token, account["token_hash"])
def delete_account(self, hostname: str) -> None:
with self.connect() as conn:
conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,))
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
def rotate_update_slug(self, hostname: str) -> AccountRow | None:
update_slug = secrets.token_urlsafe(32)
with self.connect() as conn:
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
return self.get_account_by_hostname(hostname)
def rotate_management_slug(self, hostname: str) -> AccountRow | None:
management_slug = secrets.token_urlsafe(32)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["management_slug"] = management_slug
return account
def rotate_password(self, hostname: str) -> AccountRow | None:
token = secrets.token_urlsafe(24)
token_hash = hash_secret(token)
with self.connect() as conn:
conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["password"] = token
return account
def set_dashboard_password(self, hostname: str, password: str) -> AccountRow | None:
password_hash = hash_secret(password)
with self.connect() as conn:
conn.execute(
"UPDATE accounts SET dashboard_password_hash = ? WHERE hostname = ?",
(password_hash, hostname),
)
return self.get_account_by_hostname(hostname)
def clear_dashboard_password(self, hostname: str) -> AccountRow | None:
with self.connect() as conn:
conn.execute("UPDATE accounts SET dashboard_password_hash = NULL WHERE hostname = ?", (hostname,))
return self.get_account_by_hostname(hostname)
def verify_dashboard_password(self, hostname: str, password: str | None) -> bool:
if not password:
return False
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT dashboard_password_hash, disabled FROM accounts
WHERE hostname = ?
""",
(hostname,),
).fetchone()
if not account or account["disabled"] or not account["dashboard_password_hash"]:
return False
return verify_secret(password, account["dashboard_password_hash"])
def _default_username(hostname: str) -> str:
label = hostname.split(".", 1)[0]
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import secrets
import sqlite3
from datetime import UTC, datetime
from ..ddns_security import hash_lookup_token
from .types import DomainChallengeRow
class DomainsMixin:
def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> DomainChallengeRow:
token = f"ff-ddns-{secrets.token_urlsafe(24)}"
claim_secret = secrets.token_urlsafe(32)
claim_hash = hash_lookup_token(claim_secret)
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
existing = conn.execute(
"SELECT verified_at, owner_hostname FROM domain_challenges WHERE domain = ?",
(domain,),
).fetchone()
if existing and existing[0]:
raise sqlite3.IntegrityError("domain is already verified")
if existing and existing[1] and existing[1] != owner_hostname:
raise sqlite3.IntegrityError("domain claim belongs to another dashboard")
conn.execute(
"""
INSERT INTO domain_challenges(domain, token, claim_hash, owner_hostname, created_at, verified_at)
VALUES (?, ?, ?, ?, ?, NULL)
ON CONFLICT(domain) DO UPDATE SET
token=excluded.token,
claim_hash=excluded.claim_hash,
owner_hostname=excluded.owner_hostname,
created_at=excluded.created_at,
verified_at=NULL
""",
(domain, token, claim_hash, owner_hostname, now),
)
return {"domain": domain, "token": token, "claim_secret": claim_secret}
def verify_domain_challenge(
self,
domain: str,
claim_secret: str,
owner_hostname: str | None = None,
) -> DomainChallengeRow | None:
now = datetime.now(UTC).isoformat()
claim_hash = hash_lookup_token(claim_secret)
with self.connect() as conn:
conn.row_factory = sqlite3.Row
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ? AND claim_hash = ?"
params = [domain, claim_hash]
if owner_hostname is not None:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
challenge = conn.execute(sql, tuple(params)).fetchone()
if not challenge:
return None
update_sql = "UPDATE domain_challenges SET verified_at = ? WHERE domain = ? AND claim_hash = ?"
update_params = [now, domain, claim_hash]
if owner_hostname is not None:
update_sql += " AND owner_hostname = ?"
update_params.append(owner_hostname)
cursor = conn.execute(update_sql, tuple(update_params))
if cursor.rowcount != 1:
return None
result = dict(challenge)
result["verified_at"] = now
result["claim_secret"] = claim_secret
return result
def get_domain_challenge(
self,
domain: str,
claim_secret: str | None = None,
owner_hostname: str | None = None,
) -> DomainChallengeRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ?"
params = [domain]
if claim_secret is not None:
sql += " AND claim_hash = ?"
params.append(hash_lookup_token(claim_secret))
if owner_hostname is not None:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
challenge = conn.execute(sql, tuple(params)).fetchone()
return dict(challenge) if challenge else None
def get_domain_challenge_by_secret(self, claim_secret: str) -> DomainChallengeRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
challenge = conn.execute(
"SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE claim_hash = ?",
(hash_lookup_token(claim_secret),),
).fetchone()
if not challenge:
return None
result = dict(challenge)
result["claim_secret"] = claim_secret
return result
def list_domain_challenges(self, limit: int = 100, offset: int = 0) -> list[DomainChallengeRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT domain, owner_hostname, created_at, verified_at
FROM domain_challenges
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_domain_challenges(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM domain_challenges").fetchone()
return int(row[0])
def is_domain_verified(
self,
domain: str,
claim_secret: str | None = None,
owner_hostname: str | None = None,
) -> bool:
with self.connect() as conn:
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
params = [domain]
if claim_secret is not None:
sql += " AND claim_hash = ?"
params.append(hash_lookup_token(claim_secret))
if owner_hostname is not None:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
verified_at = conn.execute(sql, tuple(params)).fetchone()
return bool(verified_at and verified_at[0])
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import sqlite3
import time
from datetime import UTC, datetime, timedelta
from .types import CleanupRunRow
RATE_LIMIT_FLUSH_SECONDS = 10.0
class RateLimitCleanupMixin:
def list_cleanup_runs(self, limit: int = 20, offset: int = 0) -> list[CleanupRunRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
FROM cleanup_runs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [CleanupRunRow(**dict(row)) for row in rows]
def count_cleanup_runs(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM cleanup_runs").fetchone()
return int(row[0])
def allow_rate_limit(self, key: str, limit: int) -> bool:
if limit <= 0:
return False
now = time.time()
window = int(now) // 60
refill_rate = limit / 60.0
with self._rate_limit_lock:
bucket = self._rate_limit_buckets.get(key)
if bucket is None:
bucket = {"tokens": float(limit), "updated_at": now, "window": window}
else:
elapsed_seconds = max(0.0, now - float(bucket["updated_at"]))
bucket["tokens"] = min(float(limit), float(bucket["tokens"]) + elapsed_seconds * refill_rate)
bucket["updated_at"] = now
bucket["window"] = window
if float(bucket["tokens"]) < 1.0:
self._rate_limit_buckets[key] = bucket
return False
bucket["tokens"] = float(bucket["tokens"]) - 1.0
self._rate_limit_buckets[key] = bucket
dirty_key = (key, window)
self._rate_limit_dirty_counts[dirty_key] = self._rate_limit_dirty_counts.get(dirty_key, 0) + 1
self._prune_memory_rate_limits(now)
self._flush_rate_limits_if_due()
return True
def flush_rate_limits(self) -> None:
with self._rate_limit_lock:
dirty_counts = self._rate_limit_dirty_counts
self._rate_limit_dirty_counts = {}
self._last_rate_limit_flush = time.monotonic()
if not dirty_counts:
return
try:
with self.connect() as conn:
conn.executemany(
"""
INSERT INTO rate_limits(key, window, count)
VALUES (?, ?, ?)
ON CONFLICT(key, window) DO UPDATE SET count=count + excluded.count
""",
[(key, window, count) for (key, window), count in dirty_counts.items()],
)
except Exception:
with self._rate_limit_lock:
for dirty_key, count in dirty_counts.items():
self._rate_limit_dirty_counts[dirty_key] = self._rate_limit_dirty_counts.get(dirty_key, 0) + count
raise
def _flush_rate_limits_if_due(self) -> None:
if time.monotonic() - self._last_rate_limit_flush < RATE_LIMIT_FLUSH_SECONDS:
return
self.flush_rate_limits()
def _prune_memory_rate_limits(self, now: float) -> None:
stale_before = now - 120
stale_keys = [
key
for key, bucket in self._rate_limit_buckets.items()
if float(bucket["updated_at"]) < stale_before and float(bucket["tokens"]) >= 1.0
]
for key in stale_keys:
del self._rate_limit_buckets[key]
def prune_rate_limit_storage(self) -> None:
current_window = int(time.time()) // 60
should_prune = self._should_prune_rate_limits(current_window)
if not should_prune:
return
with self.connect() as conn:
conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 2,))
def _should_prune_rate_limits(self, window: int) -> bool:
if self._last_rate_limit_prune_window == window:
return False
with self._rate_limit_prune_lock:
if self._last_rate_limit_prune_window == window:
return False
self._last_rate_limit_prune_window = window
return True
def cleanup(
self,
challenge_hours: int,
unused_account_hours: int,
event_history_hours: int = 24 * 180,
) -> dict[str, int]:
self.flush_rate_limits()
challenge_cutoff = (datetime.now(UTC) - timedelta(hours=challenge_hours)).isoformat()
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
with self.connect() as conn:
challenge_cursor = conn.execute(
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?",
(challenge_cutoff,),
)
account_cursor = conn.execute(
"""
DELETE FROM accounts
WHERE created_at < ?
AND NOT EXISTS (
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
)
""",
(unused_account_cutoff,),
)
rollup_cursor = conn.execute(
"DELETE FROM update_event_rollups WHERE last_seen_at < ?",
(unused_account_cutoff,),
)
event_cursor = conn.execute(
"DELETE FROM update_events WHERE created_at < ?",
(event_history_cutoff,),
)
current_window = int(time.time()) // 60
conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 60,))
conn.execute(
"""
INSERT INTO cleanup_runs(created_at, domain_challenges_deleted, unused_accounts_deleted)
VALUES (?, ?, ?)
""",
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
)
return {
"domain_challenges": challenge_cursor.rowcount,
"unused_accounts": account_cursor.rowcount,
"event_rollups": rollup_cursor.rowcount,
"update_events": event_cursor.rowcount,
}
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from ..ddns_models import UpdateResult
from .types import UpdateEventRow
class RecordsEventsMixin:
def upsert(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(hostname, effective_ipv4, effective_ipv6, now),
)
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def commit_update_result(self, result: UpdateResult) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(result.hostname, result.ipv4, result.ipv6, now),
)
return result
def preview_update(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def list_records(self) -> list[dict[str, str | None]]:
return self.list_records_page(limit=10_000, offset=0)
def list_records_page(self, limit: int = 100, offset: int = 0) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, updated_at
FROM records
ORDER BY hostname
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_records(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
return int(row[0])
def log_update_event(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()),
)
def log_update_rollup(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_event_rollups(
hostname, ipv4, ipv6, status, detail, source_ip, first_seen_at, last_seen_at, count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(hostname, ipv4, ipv6, status, detail, source_ip) DO UPDATE SET
last_seen_at=excluded.last_seen_at,
count=count + 1
""",
(hostname, ipv4 or "", ipv6 or "", status, detail[:500], source_ip, now, now),
)
def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
return self.list_update_events_page(limit=limit, offset=offset)
def list_update_events_page(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
source_limit = limit + offset
with self.connect() as conn:
conn.row_factory = sqlite3.Row
event_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(source_limit, 0),
).fetchall()
rollup_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
ORDER BY last_seen_at DESC
LIMIT ? OFFSET ?
""",
(source_limit, 0),
).fetchall()
return _merge_event_rows(event_rows, rollup_rows, limit, offset)
def count_update_events(self) -> int:
with self.connect() as conn:
event_count = conn.execute("SELECT COUNT(*) FROM update_events").fetchone()[0]
rollup_count = conn.execute("SELECT COUNT(*) FROM update_event_rollups").fetchone()[0]
return int(event_count) + int(rollup_count)
def list_update_events_for_hostname(self, hostname: str, limit: int = 25) -> list[UpdateEventRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
event_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
WHERE hostname = ?
ORDER BY created_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
rollup_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
WHERE hostname = ?
ORDER BY last_seen_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
return _merge_event_rows(event_rows, rollup_rows, limit, 0)
def _merge_event_rows(
event_rows: list[sqlite3.Row],
rollup_rows: list[sqlite3.Row],
limit: int,
offset: int,
) -> list[UpdateEventRow]:
rows = [dict(row) for row in event_rows]
rows.extend(dict(row) for row in rollup_rows)
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
return rows[offset : offset + limit]
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime, timedelta
from .types import ScheduledTaskRow
def _now() -> datetime:
return datetime.now(UTC)
def _iso(value: datetime) -> str:
return value.isoformat()
class SchedulerMixin:
def ensure_scheduled_task(self, name: str, interval_seconds: int) -> None:
now = _now()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO scheduled_tasks(name, interval_seconds, next_run_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
interval_seconds=excluded.interval_seconds,
updated_at=excluded.updated_at
""",
(name, interval_seconds, _iso(now), _iso(now)),
)
def acquire_due_task(self, name: str, lease_owner: str, lease_seconds: int) -> bool:
now = _now()
lease_expires_at = now + timedelta(seconds=lease_seconds)
with self.connect() as conn:
cursor = conn.execute(
"""
UPDATE scheduled_tasks
SET lease_owner = ?,
lease_expires_at = ?,
last_started_at = ?,
last_status = 'running',
last_error = '',
updated_at = ?
WHERE name = ?
AND next_run_at <= ?
AND (lease_expires_at IS NULL OR lease_expires_at <= ?)
""",
(
lease_owner,
_iso(lease_expires_at),
_iso(now),
_iso(now),
name,
_iso(now),
_iso(now),
),
)
return cursor.rowcount == 1
def complete_scheduled_task(
self,
name: str,
interval_seconds: int,
success: bool,
error: str = "",
) -> None:
now = _now()
next_run_at = now + timedelta(seconds=interval_seconds)
status = "ok" if success else "error"
with self.connect() as conn:
conn.execute(
"""
UPDATE scheduled_tasks
SET next_run_at = ?,
lease_owner = NULL,
lease_expires_at = NULL,
last_finished_at = ?,
last_status = ?,
last_error = ?,
interval_seconds = ?,
updated_at = ?
WHERE name = ?
""",
(_iso(next_run_at), _iso(now), status, error[:500], interval_seconds, _iso(now), name),
)
def list_scheduled_tasks(self) -> list[ScheduledTaskRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at,
last_started_at, last_finished_at, last_status, last_error, updated_at
FROM scheduled_tasks
ORDER BY name
"""
).fetchall()
return [ScheduledTaskRow(**dict(row)) for row in rows]
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
import secrets
import sqlite3
from pathlib import Path
from ..ddns_security import hash_lookup_token
BUSY_TIMEOUT_MS = 10_000
CACHE_SIZE_KIB = 20_000
MMAP_SIZE_BYTES = 256 * 1024 * 1024
WAL_AUTOCHECKPOINT_PAGES = 1_000
SCHEMA_VERSION = 1
def connect_database(database_path: Path) -> sqlite3.Connection:
if str(database_path) != ":memory:":
database_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(database_path, check_same_thread=False)
configure_connection(conn)
return conn
def configure_connection(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA temp_store = MEMORY")
conn.execute(f"PRAGMA cache_size = -{CACHE_SIZE_KIB}")
conn.execute(f"PRAGMA mmap_size = {MMAP_SIZE_BYTES}")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute(f"PRAGMA wal_autocheckpoint = {WAL_AUTOCHECKPOINT_PAGES}")
def initialize_schema(conn: sqlite3.Connection) -> None:
configure_connection(conn)
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS records (
hostname TEXT PRIMARY KEY,
ipv4 TEXT,
ipv6 TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ipv4 TEXT,
ipv6 TEXT,
status TEXT NOT NULL,
detail TEXT NOT NULL,
source_ip TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_hostname_created_at ON update_events(hostname, created_at DESC)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_event_rollups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ipv4 TEXT,
ipv6 TEXT,
status TEXT NOT NULL,
detail TEXT NOT NULL,
source_ip TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
count INTEGER NOT NULL,
UNIQUE(hostname, ipv4, ipv6, status, detail, source_ip)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_event_rollups_last_seen_at ON update_event_rollups(last_seen_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_update_event_rollups_hostname_last_seen_at
ON update_event_rollups(hostname, last_seen_at DESC)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
domain_challenges_deleted INTEGER NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS scheduled_tasks (
name TEXT PRIMARY KEY,
interval_seconds INTEGER NOT NULL,
next_run_at TEXT NOT NULL,
lease_owner TEXT,
lease_expires_at TEXT,
last_started_at TEXT,
last_finished_at TEXT,
last_status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run_at ON scheduled_tasks(next_run_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
ON scheduled_tasks(next_run_at, lease_expires_at)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
window INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY(key, window)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE,
dashboard_password_hash TEXT,
created_at TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
_ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT")
_ensure_column(conn, "accounts", "dashboard_password_hash", "TEXT")
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
conn.execute(
"UPDATE accounts SET management_hash = ? WHERE hostname = ?",
(hash_lookup_token(secrets.token_urlsafe(32)), row[0]),
)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_hostname_username ON accounts(hostname, username)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS domain_challenges (
domain TEXT PRIMARY KEY,
token TEXT NOT NULL,
claim_hash TEXT,
owner_hostname TEXT,
created_at TEXT NOT NULL,
verified_at TEXT
)
"""
)
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
_ensure_column(conn, "domain_challenges", "owner_hostname", "TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_owner_hostname ON domain_challenges(owner_hostname)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_owner_verified ON domain_challenges(owner_hostname, verified_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_claim_hash ON domain_challenges(claim_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_created_at ON domain_challenges(created_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_domain_challenges_unverified_created
ON domain_challenges(created_at)
WHERE verified_at IS NULL
"""
)
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.execute("PRAGMA optimize")
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
if column not in columns:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import TypedDict
class AccountRow(TypedDict, total=False):
hostname: str
username: str
token_hash: str
update_slug: str
management_slug: str
dashboard_password_hash: str | None
created_at: str
disabled: int
ipv4: str | None
ipv6: str | None
updated_at: str | None
password: str
class DomainChallengeRow(TypedDict, total=False):
domain: str
token: str
claim_secret: str
owner_hostname: str | None
created_at: str
verified_at: str | None
class UpdateEventRow(TypedDict, total=False):
hostname: str
ipv4: str | None
ipv6: str | None
status: str
detail: str
source_ip: str
created_at: str
count: int
class CleanupRunRow(TypedDict):
created_at: str
domain_challenges_deleted: int
unused_accounts_deleted: int
class ScheduledTaskRow(TypedDict, total=False):
name: str
interval_seconds: int
next_run_at: str
lease_owner: str | None
lease_expires_at: str | None
last_started_at: str | None
last_finished_at: str | None
last_status: str
last_error: str
updated_at: str
View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import urllib.parse
from fastapi import HTTPException, Request
from ..ddns_models import DdnsSettings
MAX_FORM_FIELDS = 100
def parse_form(body: bytes) -> dict[str, list[str]]:
try:
decoded = body.decode("utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="form body must be valid UTF-8") from exc
try:
return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields") from exc
async def request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]:
body = await request.body()
if len(body) > settings.max_request_body_bytes:
raise HTTPException(status_code=413, detail="request body too large")
return parse_form(body)
def first_form_value(form: dict[str, list[str]], key: str) -> str:
return form.get(key, [""])[0].strip()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import html
class SafeHtml(str):
"""HTML fragment that was built by trusted server-side template helpers."""
def escape(value: object) -> str:
return html.escape(str(value), quote=True)
def attr(value: object) -> str:
return escape(value)
def trusted(fragment: str) -> SafeHtml:
return SafeHtml(fragment)
def join_html(fragments: list[str]) -> SafeHtml:
return SafeHtml("".join(fragments))
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..ddns_models import DdnsSettings
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..ddns_store import DdnsStore
class RequestBodyTooLargeError(Exception):
pass
class RequestBodyLimitMiddleware:
def __init__(self, app: ASGIApp, max_body_bytes: int) -> None:
self.app = app
self.max_body_bytes = max_body_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
received = 0
async def limited_receive() -> Message:
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > self.max_body_bytes:
raise RequestBodyTooLargeError
return message
try:
await self.app(scope, limited_receive, send)
except RequestBodyTooLargeError:
response = PlainTextResponse("request body too large", status_code=413)
await response(scope, receive, send)
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
try:
content_length = int(request.headers.get("content-length", "0") or "0")
except ValueError:
content_length = 0
if content_length > settings.max_request_body_bytes:
return PlainTextResponse("request body too large", status_code=413)
if is_rate_limited_path(request.url.path) or is_admin_path(request.url.path, settings):
limit = (
settings.admin_rate_limit_per_minute
if is_admin_path(request.url.path, settings)
else settings.rate_limit_per_minute
)
key = f"{client_ip(request, settings)}:{rate_limit_bucket(request.url.path)}"
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed:
return PlainTextResponse("rate limit exceeded", status_code=429)
response = await call_next(request)
if is_secret_response(request):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
response.headers["Content-Security-Policy"] = content_security_policy(request.url.path)
if settings.public_base_url.startswith("https://"):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
admin_path = settings.admin_path.rstrip("/")
admin_records = f"{admin_path}/records"
admin_events = f"{admin_path}/events"
legacy_admin_api = settings.admin_path == "/admin" and path in {"/records", "/events"}
return path == admin_path or path.startswith(f"{admin_path}/") or path in {admin_records, admin_events} or legacy_admin_api
def is_secret_response(request: Request) -> bool:
path = request.url.path
settings = request.app.state.settings
if is_admin_path(path, settings) or path.startswith(("/m/", "/d/", "/u/")):
return True
if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
return request.method in {"POST", "DELETE"}
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
def content_security_policy(path: str) -> str:
if path in {"/docs", "/redoc", "/openapi.json"}:
return (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https://fastapi.tiangolo.com; "
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
return (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' https://cdn.jsdelivr.net; "
"img-src 'self' data:; "
"font-src 'self' https://cdn.jsdelivr.net; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
+780
View File
@@ -0,0 +1,780 @@
from __future__ import annotations
APP_CSS = """
:root {
color-scheme: light;
--bs-primary: #000000;
--bs-primary-rgb: 0, 0, 0;
--bs-link-color: #000000;
--bs-link-hover-color: #3f3f46;
--bs-border-radius: 12px;
--bs-border-radius-sm: 10px;
--bs-border-radius-lg: 12px;
--rp-bg: #ffffff;
--rp-canvas: #ffffff;
--rp-surface: #ffffff;
--rp-surface-alt: #fafafa;
--rp-field: #fafafa;
--rp-ink: #000000;
--rp-muted: #737373;
--rp-charcoal: #52525b;
--rp-mute: #a1a1aa;
--rp-line: #e5e5e5;
--rp-soft-line: #eeeeee;
--rp-danger: #b42318;
--rp-focus-ring: rgba(0, 0, 0, 0.18);
--rp-terminal: #ffffff;
--rp-terminal-ink: #000000;
--rp-success: #1d6f42;
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-body-bg: #0f0f0f;
--bs-body-color: #f5f5f5;
--bs-secondary-color: #a1a1aa;
--bs-border-color: rgba(255, 255, 255, 0.16);
--bs-tertiary-bg: #171717;
--rp-bg: #0f0f0f;
--rp-canvas: #0f0f0f;
--rp-surface: #171717;
--rp-surface-alt: #1f1f1f;
--rp-field: #1f1f1f;
--rp-ink: #f5f5f5;
--rp-muted: #a3a3a3;
--rp-charcoal: #d4d4d4;
--rp-mute: #737373;
--rp-line: rgba(255, 255, 255, 0.16);
--rp-soft-line: rgba(255, 255, 255, 0.1);
--rp-danger: #ff8a80;
--rp-focus-ring: rgba(255, 255, 255, 0.24);
--rp-terminal: #171717;
--rp-terminal-ink: #f5f5f5;
--rp-success: #62c383;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
overflow-x: clip;
}
body {
min-height: 100vh;
overflow-x: clip;
background: var(--rp-bg);
color: var(--rp-ink);
font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.justify-items-center {
justify-items: center;
}
.container {
max-width: 1040px;
}
.container.narrow {
max-width: 760px;
}
.skip-link {
position: fixed;
left: 16px;
top: 10px;
z-index: 40;
transform: translateY(-140%);
padding: 8px 20px;
border-radius: 999px;
background: var(--rp-ink);
color: var(--rp-bg);
text-decoration: none;
font-size: 14px;
transition: transform 0.16s ease;
}
.skip-link:focus {
transform: translateY(0);
}
.site-nav {
min-height: 63px;
padding: 14px 0;
border-bottom: 0;
background: var(--rp-canvas);
}
.site-nav .container {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
color: var(--rp-ink);
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
white-space: nowrap;
}
.brand:hover {
color: var(--rp-ink);
}
.brand img {
width: 26px;
height: 26px;
display: block;
}
.nav-label {
max-width: min(38vw, 320px);
}
.site-nav-actions {
min-width: 0;
flex-wrap: wrap;
}
.navbar .nav-link,
.navbar-text {
color: var(--rp-muted);
font-size: 13px;
font-weight: 500;
}
.navbar .nav-link:hover {
color: var(--rp-ink);
}
.theme-toggle {
min-width: 58px;
}
.hero-band {
min-height: auto;
display: grid;
align-items: center;
border-bottom: 0;
background: var(--rp-canvas);
text-align: center;
padding: 96px 0 88px;
}
.hero-band .container {
max-width: 720px;
min-width: 0;
}
.hero-band .container > * {
max-width: 100%;
min-width: 0;
}
.hero-logo {
width: 80px;
height: 80px;
margin-inline: auto;
}
.hero-band.compact {
min-height: auto;
padding: 56px 0 48px;
}
.section {
padding: 88px 0;
border-bottom: 0;
background: var(--rp-canvas);
}
.section + .section {
border-top: 0;
}
.success-section {
background: var(--rp-surface-alt);
}
.danger-section {
background: var(--rp-surface);
border-top: 1px solid var(--rp-soft-line);
}
.page-heading,
.admin-heading .container {
max-width: 760px;
}
.admin-heading {
padding: 56px 0 48px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1,
h2 {
font-family: "SF Pro Rounded", ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
h1 {
max-width: 720px;
font-size: 36px;
line-height: 1.11;
font-weight: 500;
letter-spacing: 0;
overflow-wrap: anywhere;
}
h2 {
font-size: 28px;
line-height: 1.22;
font-weight: 500;
letter-spacing: 0;
}
h3 {
font-size: 19px;
line-height: 1.35;
font-weight: 500;
}
.eyebrow {
color: var(--rp-charcoal);
font-size: 13px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
.lead {
max-width: 660px;
color: var(--rp-muted);
font-size: 16px;
line-height: 1.5;
letter-spacing: 0;
}
.section-copy,
.intro {
margin-top: 12px;
max-width: 560px;
color: var(--rp-muted);
font-size: 15px;
line-height: 1.5;
}
.install-pill {
display: inline-flex;
align-items: center;
max-width: 100%;
min-width: 0;
min-height: 48px;
padding: 12px 20px;
border: 0;
border-radius: 9999px;
background: var(--rp-field);
color: var(--rp-ink);
}
.install-pill code {
min-width: 0;
overflow: hidden;
color: var(--rp-ink);
text-overflow: ellipsis;
white-space: nowrap;
font: 400 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.card {
background: var(--rp-surface);
border: 1px solid var(--rp-line);
border-radius: 16px;
box-shadow: none;
overflow: clip;
}
.card-body {
padding: 24px;
}
label {
display: grid;
gap: 8px;
color: var(--rp-charcoal);
font-size: 14px;
font-weight: 500;
}
.form-control,
input {
width: 100%;
min-height: 44px;
border: 1px solid var(--rp-line);
border-radius: 9999px;
padding: 10px 16px;
color: var(--bs-body-color);
font: inherit;
background: var(--rp-field);
outline: none;
}
.form-select {
min-height: 38px;
border-color: var(--rp-line);
border-radius: 9999px;
background-color: var(--rp-field);
color: var(--rp-ink);
}
.form-control:focus,
input:focus,
.form-select:focus {
border-color: var(--rp-ink);
box-shadow: 0 0 0 3px var(--rp-focus-ring);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 9999px;
font-size: 14px;
font-weight: 600;
line-height: 1;
transition: background-color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.btn:not(.btn-sm) {
min-height: 42px;
padding: 10px 22px;
}
.btn-sm {
min-width: 34px;
min-height: 34px;
padding-inline: 14px;
border-radius: 9999px;
}
.btn:active {
transform: scale(0.97);
}
.btn:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--rp-focus-ring);
outline-offset: 3px;
}
.btn-primary {
--bs-btn-color: #ffffff;
--bs-btn-bg: #000000;
--bs-btn-border-color: #000000;
--bs-btn-hover-color: #ffffff;
--bs-btn-hover-bg: #1c1c1c;
--bs-btn-hover-border-color: #1c1c1c;
--bs-btn-active-color: #ffffff;
--bs-btn-active-bg: #1c1c1c;
--bs-btn-active-border-color: #1c1c1c;
--bs-btn-disabled-bg: var(--rp-surface-alt);
--bs-btn-disabled-border-color: var(--rp-line);
--bs-btn-disabled-color: var(--rp-mute);
}
.btn-outline-primary {
--bs-btn-color: var(--rp-ink);
--bs-btn-border-color: var(--rp-line);
--bs-btn-hover-color: var(--rp-ink);
--bs-btn-hover-bg: var(--rp-surface-alt);
--bs-btn-hover-border-color: var(--rp-line);
--bs-btn-active-color: var(--rp-ink);
--bs-btn-active-bg: var(--rp-surface-alt);
--bs-btn-active-border-color: var(--rp-line);
}
.btn-outline-secondary,
.btn-light {
--bs-btn-bg: var(--rp-field);
--bs-btn-border-color: var(--rp-line);
--bs-btn-color: var(--rp-ink);
--bs-btn-hover-bg: var(--rp-surface-alt);
--bs-btn-hover-border-color: var(--rp-line);
--bs-btn-hover-color: var(--rp-ink);
}
.btn-outline-danger {
--bs-btn-color: var(--rp-danger);
--bs-btn-border-color: var(--rp-line);
--bs-btn-hover-color: #ffffff;
--bs-btn-hover-bg: var(--rp-danger);
--bs-btn-hover-border-color: var(--rp-danger);
}
.text-primary {
color: var(--rp-ink) !important;
}
.text-bg-primary {
color: #ffffff !important;
background-color: #000000 !important;
}
.text-bg-secondary,
.text-bg-warning {
color: var(--rp-charcoal) !important;
background-color: var(--rp-field) !important;
border: 1px solid var(--rp-line);
}
.text-bg-primary.rounded-circle {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
border-radius: 9999px !important;
}
.alert-info,
.alert-warning {
--bs-alert-color: var(--rp-charcoal);
--bs-alert-bg: var(--rp-surface-alt);
--bs-alert-border-color: var(--rp-line);
border-radius: 12px;
}
.copy-field {
min-width: 0;
max-width: 100%;
}
.copy-field .input-group-text {
min-width: 150px;
border-color: var(--rp-line);
color: var(--rp-muted);
font-size: 13px;
background: var(--rp-surface-alt);
}
.copy-label {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.copy-field .form-control {
min-width: 0;
border-color: var(--rp-line);
background: var(--rp-field);
}
.copy-field .copy-value {
min-width: 0;
overflow-wrap: anywhere;
}
.copy-field code.form-control {
width: 1%;
flex: 1 1 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.copy-field code {
min-height: 42px;
margin: 0;
color: var(--rp-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
background: var(--rp-field);
}
.manual-code {
overflow-x: auto;
padding: 16px;
border: 1px solid var(--rp-line);
border-radius: 12px;
color: var(--rp-terminal-ink);
background: var(--rp-terminal);
font: 400 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.terminal-card {
display: grid;
gap: 8px;
padding: 18px 20px;
border: 1px solid var(--rp-line);
border-radius: 12px;
color: var(--rp-terminal-ink);
background: var(--rp-terminal);
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.terminal-card code {
display: block;
overflow-wrap: anywhere;
color: inherit;
font: inherit;
}
.admin-table-scroll {
max-height: 560px;
overflow: auto;
background: var(--rp-surface);
border-color: var(--rp-line) !important;
border-radius: 16px !important;
-webkit-overflow-scrolling: touch;
}
.admin-table-scroll thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--rp-surface-alt);
}
.table-page-size {
width: auto;
min-width: 76px;
}
.admin-table-shell .form-control {
max-width: 360px;
}
.status-table-shell {
max-width: 100%;
border: 1px solid var(--rp-line);
border-radius: 16px;
background: var(--rp-surface);
}
.pagination {
--bs-pagination-color: var(--rp-muted);
--bs-pagination-bg: var(--rp-surface);
--bs-pagination-border-color: var(--rp-line);
--bs-pagination-hover-color: var(--rp-ink);
--bs-pagination-hover-bg: var(--rp-field);
--bs-pagination-hover-border-color: var(--rp-line);
--bs-pagination-active-color: #ffffff;
--bs-pagination-active-bg: #000000;
--bs-pagination-active-border-color: #000000;
--bs-pagination-disabled-color: var(--rp-mute);
--bs-pagination-disabled-bg: var(--rp-surface);
--bs-pagination-disabled-border-color: var(--rp-line);
}
.site-footer {
padding: 32px 0;
background: var(--rp-canvas);
}
.site-footer .nav-link:hover {
color: var(--rp-ink) !important;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: var(--rp-ink);
}
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--rp-ink);
--bs-table-border-color: var(--rp-soft-line);
--bs-table-hover-bg: var(--rp-field);
margin-bottom: 0;
}
th,
td {
border-bottom: 1px solid var(--rp-soft-line);
padding: 14px 10px;
text-align: left;
white-space: nowrap;
}
td {
max-width: 38rem;
overflow: hidden;
text-overflow: ellipsis;
}
.status-table {
min-width: min(100%, 420px);
}
.status-table th,
.status-table td {
white-space: normal;
overflow-wrap: anywhere;
}
tr:last-child th,
tr:last-child td {
border-bottom: 0;
}
th {
color: var(--rp-muted);
font-size: 12px;
font-weight: 600;
background: var(--rp-surface-alt);
}
.empty {
color: var(--rp-muted);
text-align: center;
padding: 28px;
}
@media (max-width: 860px) {
.section,
.hero-band {
padding: 64px 0;
}
.hero-band {
min-height: auto;
}
h1 {
font-size: 32px;
}
}
@media (max-width: 560px) {
.container,
.container.narrow,
.hero-band .container {
width: 100%;
max-width: 100%;
}
.site-nav {
padding: 10px 0;
}
.site-nav .container {
align-items: flex-start;
}
.site-nav-actions {
max-width: calc(100% - 108px);
row-gap: 8px;
}
.brand {
font-size: 14px;
}
.brand img {
width: 24px;
height: 24px;
}
.navbar .nav-link,
.navbar-text {
font-size: 12px;
}
.nav-label {
max-width: 100%;
}
.hero-band .btn,
.card button,
.danger-section form,
.danger-section button {
width: 100%;
}
.install-pill {
width: 100%;
}
.hero-logo {
width: 68px;
height: 68px;
}
.copy-field {
display: grid;
grid-template-columns: 1fr;
}
.copy-field .input-group-text {
min-width: 0;
border-radius: 14px 14px 0 0;
white-space: normal;
}
.copy-field .copy-value,
.copy-field code {
width: 100%;
white-space: normal;
overflow-wrap: anywhere;
border-radius: 0;
}
.copy-field .btn {
width: 100%;
border-radius: 0;
}
.copy-field .btn:last-child {
border-radius: 0 0 14px 14px;
}
.admin-table-shell .form-control {
max-width: none;
}
.admin-table-shell [data-table-count] {
white-space: normal !important;
}
.pagination {
flex-wrap: wrap;
justify-content: flex-start !important;
}
th,
td {
padding: 12px 9px;
}
h1 {
font-size: 28px;
line-height: 1.15;
}
h2 {
font-size: 24px;
}
}
""".strip()
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from .html_utils import SafeHtml, attr, escape, join_html, trusted
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
if label:
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">Docs</a><a class="nav-link px-0" href="/redoc">API</a>')
account_html += '<a class="btn btn-sm btn-primary rounded-pill" href="/#create">Generate</a>'
theme_toggle = '<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch color theme">Light</button>'
return trusted(
f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo.png" alt="" width="28" height="28">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
{links}
{account_html}
{theme_toggle}
</div>
</div>
</nav>
"""
)
def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtml:
safe_label = escape(label)
safe_value = escape(value)
badge = ""
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
if sensitivity:
safe_sensitivity = escape(sensitivity)
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
value_field = (
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
if is_secret
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
)
reveal_button = (
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>'
if is_secret
else ""
)
return trusted(
f"""
<div class="input-group copy-field">
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
{value_field}
{reveal_button}
<button type="button" class="btn btn-outline-secondary" data-copy="{safe_value}" aria-label="Copy {safe_label}">Copy</button>
</div>
"""
)
def _copy_button(label: str, value: str) -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
return trusted(f'<button type="button" class="btn btn-outline-primary rounded-pill" data-copy="{safe_value}">{safe_label}</button>')
def _manual_update_commands(update_url: str) -> str:
update_base = update_url.split("?", 1)[0]
return "\n".join(
[
f"curl -sS '{update_base}?myip=203.0.113.10'",
f"curl -sS '{update_base}?myipv6=2001:db8::10'",
f"curl -sS '{update_base}'",
]
)
def _manual_update_panel(commands: str) -> SafeHtml:
safe_commands = escape(commands)
return trusted(
f"""
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box replaces <code>&lt;ipaddr&gt;</code> and <code>&lt;ip6addr&gt;</code>, then sends a GET request to the Update-URL. Scripts and other routers can do the same with curl. If no IP parameter is sent, KarlDNS uses the request source IP.</p>
</div>
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
{_copy_button("Copy curl examples", commands)}
</div>
"""
)
def _fritzbox_fields(
update_url: str,
hostname: str,
username: str,
password: str,
management_url: str | None = None,
) -> str:
lines = [
f"Update-URL: {update_url}",
f"Domainnamen: {hostname}",
f"CNAME target: {hostname}",
f"Benutzername: {username}",
f"Kennwort: {password}",
]
if management_url:
lines.append(f"Private status page: {management_url}")
return "\n".join(lines)
def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Domainnamen and CNAME target can be shared in DNS.
<strong>Secret:</strong> Update-URL, one-box update URL, optional Kennwort, private dashboard URL, and domain claim links control the hostname.
Secrets are hidden by default on screen; Copy still copies the real value.
</div>
"""
)
def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the router password and one-box update URL now. They are only shown when generated or rotated and are not recoverable from the dashboard later.
</div>
"""
)
def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
def _site_footer() -> SafeHtml:
return trusted(
"""
<footer class="site-footer border-top">
<div class="container d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row">
<div class="small text-secondary">Made by Karl</div>
<nav class="nav justify-content-center gap-3" aria-label="Footer links">
<a class="nav-link p-0 small text-secondary" href="https://github.com/StasonJatham/karldns.de" rel="noopener">GitHub</a>
<a class="nav-link p-0 small text-secondary" href="https://karl.fail" rel="noopener">karl.fail</a>
<a class="nav-link p-0 small text-secondary" href="https://karlcom.de" rel="noopener">karlcom.de</a>
<a class="nav-link p-0 small text-secondary" href="/docs">API docs</a>
<a class="nav-link p-0 small text-secondary" href="/redoc">ReDoc</a>
<a class="nav-link p-0 small text-secondary" href="/privacy">Legal & privacy</a>
</nav>
</div>
</footer>
"""
)
def _page(title: str, body: SafeHtml) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
return f"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<script src="/app.js" defer></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/app.css" rel="stylesheet">
</head>
<body>{body}{_site_footer()}</body>
</html>
"""
__all__ = [
"SafeHtml",
"_copy_button",
"_copy_row",
"_fritzbox_fields",
"_manual_update_commands",
"_manual_update_panel",
"_message_band",
"_one_time_secret_notice",
"_page",
"_secret_notice",
"_top_nav",
"join_html",
"trusted",
]
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
from urllib.parse import urlencode
from ..ddns_models import DdnsSettings
from ..ddns_security import admin_csrf_token
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import _page, _top_nav
from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_cleanup_row,
_domain_claim_row,
_event_row,
_scheduled_task_row,
)
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
return trusted(f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Admin action</p>
<h2>{escape(title)}</h2>
<p class="section-copy">Copy this now. Secret values are only shown in this response.</p>
<div class="d-grid gap-2">
{"".join(rows)}
</div>
</div>
</section>
""")
def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: list[dict[str, str | int | None]],
domain_challenges: list[dict[str, str | None]],
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
table_counts: dict[str, int],
table_offsets: dict[str, int],
table_limit: int,
notice_html: SafeHtml | None,
) -> str:
csrf = attr(admin_csrf_token(settings))
admin_path = settings.admin_path
safe_admin_path = attr(admin_path)
safe_records_path = attr(f"{admin_path}/records")
safe_events_path = attr(f"{admin_path}/events")
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
"""
domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """
<tr class="empty-row"><td colspan="5" class="empty">No domain claims yet.</td></tr>
"""
cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """
<tr class="empty-row"><td colspan="3" class="empty">No cleanup runs yet.</td></tr>
"""
scheduled_task_rows = "\n".join(_scheduled_task_row(task) for task in scheduled_tasks) or """
<tr class="empty-row"><td colspan="6" class="empty">No scheduled tasks yet.</td></tr>
"""
event_rows = "\n".join(_event_row(event) for event in update_events) or """
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
"""
accounts_count = escape(table_counts.get("accounts", len(accounts)))
domain_count = escape(table_counts.get("domain_challenges", len(domain_challenges)))
cleanup_count = escape(table_counts.get("cleanup_runs", len(cleanup_runs)))
event_count = escape(table_counts.get("update_events", len(update_events)))
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
domain_pager = _server_page_links(
admin_path,
"domain_claims_offset",
table_offsets.get("domain_challenges", 0),
table_limit,
table_counts.get("domain_challenges", 0),
)
cleanup_pager = _server_page_links(admin_path, "cleanup_runs_offset", table_offsets.get("cleanup_runs", 0), table_limit, table_counts.get("cleanup_runs", 0))
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator console", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
<section class="section admin-heading">
<div class="container">
<p class="eyebrow">KarlDNS aktiv</p>
<h1>Operator console</h1>
<p class="lead">Inspect hostnames, verify domain claims, rotate links, run cleanup, and review router update events.</p>
</div>
</section>
{notice_html or ""}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<p class="eyebrow">Service health</p>
<h2>Scheduled cleanup</h2>
<p class="section-copy">Cleanup runs every {escape(settings.cleanup_interval_seconds)} seconds. It removes unverified domain claims after {escape(settings.cleanup_challenge_hours)} hours and never-used generated hostnames after {escape(settings.cleanup_unused_account_hours)} hours.</p>
<p class="section-copy">The scheduler state is stored in SQLite. Multiple app workers coordinate with a database lease, so only one worker runs a due cleanup task.</p>
</div>
<form method="post" action="{safe_admin_path}/cleanup" class="col-lg-5 card card-body gap-3" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run cleanup now</button>
</form>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Hostnames</p>
<h2>Active credentials</h2>
<p class="section-copy mb-0">Showing up to {escape(len(accounts))} of {accounts_count} hostnames.</p>
</div>
</div>
{_admin_table_toolbar("hostnames", "Search hostnames")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
<tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th>Links</th><th></th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{account_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Domain claims</p>
<h2>Verification lifecycle</h2>
<p class="section-copy mb-0">Showing up to {escape(len(domain_challenges))} of {domain_count} claims.</p>
</div>
</div>
{_admin_table_toolbar("domain-claims", "Search domain claims")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Domain</th><th>Owner dashboard</th><th>Status</th><th>Created</th><th>Verified</th></tr></thead>
<tbody>{domain_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{domain_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Scheduler state</h2>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll mb-4">
<table class="table mb-0">
<thead><tr><th>Task</th><th>Status</th><th>Next run</th><th>Lease expires</th><th>Last finished</th><th>Last error</th></tr></thead>
<tbody>{scheduled_task_rows}</tbody>
</table>
</div>
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Recent runs</h2>
<p class="section-copy mb-0">Showing up to {escape(len(cleanup_runs))} of {cleanup_count} runs.</p>
</div>
</div>
{_admin_table_toolbar("cleanup-runs", "Search cleanup runs")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain claims removed</th><th>Unused hostnames removed</th></tr></thead>
<tbody>{cleanup_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{cleanup_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Updates</p>
<h2>Recent events</h2>
<p class="section-copy mb-0">Showing up to {escape(len(update_events))} of {event_count} event rows. Repeated unchanged polls are aggregated.</p>
</div>
</div>
{_admin_table_toolbar("update-events", "Search update events")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{event_pager}
</div>
</section>
</main>
"""),
)
def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: int, total: int) -> SafeHtml:
if total <= limit:
return trusted("")
previous_offset = max(0, offset - limit)
next_offset = offset + limit
previous_disabled = " disabled" if offset <= 0 else ""
next_disabled = " disabled" if next_offset >= total else ""
previous_href = attr(f"{admin_path}?{urlencode({offset_name: previous_offset})}")
next_href = attr(f"{admin_path}?{urlencode({offset_name: next_offset})}")
start = min(offset + 1, total)
end = min(offset + limit, total)
return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-3" aria-label="Server-side table pages">
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous server page</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next server page</a>
</nav>
""")
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
hostname = escape(account["hostname"])
username = escape(account["username"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
safe_admin_path = attr(admin_path)
return f"""
<tr>
<td>{hostname}</td><td>{username}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/rotate" class="d-flex gap-2 flex-wrap">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Rotate update URL" aria-label="Rotate update URL for {hostname}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Rotate management link" aria-label="Rotate management link for {hostname}">Manage link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Rotate router password" aria-label="Rotate router password for {hostname}">Password</button>
</form>
</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button class="btn btn-sm btn-light px-2 text-danger" title="Delete hostname" aria-label="Delete {hostname}">x</button>
</form>
</td>
</tr>
"""
def _admin_table_toolbar(table_id: str, search_label: str) -> str:
safe_id = attr(table_id)
safe_label = attr(search_label)
return f"""
<div class="admin-table-shell mt-3" data-admin-table>
<div class="d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row mb-3">
<label class="w-100 mb-0" for="{safe_id}-search">
<span class="visually-hidden">{safe_label}</span>
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}" data-table-search autocomplete="off">
</label>
<div class="d-flex align-items-center gap-2 flex-shrink-0">
<span class="small text-secondary text-nowrap" data-table-count>0-0 of 0</span>
<label class="visually-hidden" for="{safe_id}-page-size">Rows per page</label>
<select id="{safe_id}-page-size" class="form-select form-select-sm table-page-size" data-table-page-size>
<option value="10">10</option>
<option value="25" selected>25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
"""
def _admin_table_pagination() -> str:
return """
<nav class="mt-3" aria-label="Table pagination">
<ul class="pagination pagination-sm justify-content-end mb-0" data-table-pagination></ul>
</nav>
</div>
"""
def _domain_claim_row(challenge: dict[str, str | None]) -> str:
domain = escape(challenge["domain"])
owner = escape(challenge.get("owner_hostname") or "-")
created = escape(challenge.get("created_at") or "-")
verified = escape(challenge.get("verified_at") or "-")
status = "Verified" if challenge.get("verified_at") else "Pending"
return f"<tr><td>{domain}</td><td>{owner}</td><td>{status}</td><td>{created}</td><td>{verified}</td></tr>"
def _cleanup_row(run: dict[str, str | int]) -> str:
created = escape(run.get("created_at") or "-")
domains = escape(run.get("domain_challenges_deleted") or 0)
accounts = escape(run.get("unused_accounts_deleted") or 0)
return f"<tr><td>{created}</td><td>{domains}</td><td>{accounts}</td></tr>"
def _scheduled_task_row(task: dict[str, str | int | None]) -> str:
name = escape(task.get("name") or "-")
status = escape(task.get("last_status") or "-")
next_run = escape(task.get("next_run_at") or "-")
lease_expires = escape(task.get("lease_expires_at") or "-")
last_finished = escape(task.get("last_finished_at") or "-")
last_error = escape(task.get("last_error") or "-")
return f"<tr><td>{name}</td><td>{status}</td><td>{next_run}</td><td>{lease_expires}</td><td>{last_finished}</td><td>{last_error}</td></tr>"
def _event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
hostname = escape(event.get("hostname") or "-")
status = escape(event.get("status") or "-")
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
return f"<tr><td>{created}</td><td>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
+377
View File
@@ -0,0 +1,377 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
_copy_button,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_page,
_secret_notice,
_top_nav,
)
from .templates_public_sections import _credentials_panel
def _render_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
challenge: dict[str, str | None] | None = None,
created_account: dict[str, str] | None = None,
) -> str:
account_for_url = {
"hostname": str(account["hostname"]),
"username": str(account["username"]),
"password": "",
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
hostname = escape(account["hostname"])
username = escape(account["username"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated")
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
manual_commands = _manual_update_commands(update_url)
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)) or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
message_html = _management_message(message)
rotated_credentials = _credentials_panel(service, created_account)
domain_claim = _management_domain_claim_panel(service, management_slug, csrf_token, challenge)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
return _page(
"KarlDNS management",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Magic management</p>
<h1>{hostname}</h1>
<p class="lead">Anyone with this link can manage or delete this hostname. Keep it private.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Back home</a>
</div>
</section>
{message_html}
{rotated_credentials}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<h2>Router update settings</h2>
<p class="section-copy">Use the Update-URL directly if your router only has one URL field. Separate Benutzername and Kennwort values are optional compatibility fields. If you use your own DNS name, point it at this hostname with a CNAME.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap mt-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy CNAME record", cname_record)}
</div>
</div>
<div class="col-lg-6 d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
</div>
</div>
{_manual_update_panel(manual_commands)}
<div class="card card-body gap-3 mt-4">
<div>
<p class="eyebrow">Actions</p>
<h3>Rotate dashboard secrets</h3>
<p class="section-copy">Rotate the URL used by routers, or issue a new router password for compatibility clients. Update your router after rotating.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate Update-URL</button>
</form>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="password">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate router credentials</button>
</form>
</div>
</div>
</div>
</section>
{domain_claim}
{dashboard_password_panel}
<section class="section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Current address</p>
<h2>Status</h2>
</div>
<div class="table-responsive status-table-shell">
<table class="table status-table mb-0">
<tbody>
<tr><th>IPv4</th><td>{ipv4}</td></tr>
<tr><th>IPv6</th><td>{ipv6}</td></tr>
<tr><th>Updated</th><td>{updated}</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">History</p>
<h2>Router updates</h2>
<p class="section-copy">Recent IP changes and failed DNS publishes for this hostname.</p>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{history_rows}</tbody>
</table>
</div>
</div>
</section>
<section class="section danger-section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Danger zone</p>
<h2>Delete hostname</h2>
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/delete">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
</div>
</section>
</main>
"""),
)
def _management_message(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
password_status = "Enabled" if password_enabled else "Not enabled"
password_action = "Change dashboard password" if password_enabled else "Set dashboard password"
remove_form = ""
if password_enabled:
remove_form = f"""
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password/remove">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label class="form-label" for="dashboard-remove-current-password">Current dashboard password</label>
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove dashboard password</button>
</form>
"""
current_password_field = ""
if password_enabled:
current_password_field = """
<div>
<label class="form-label" for="dashboard-current-password">Current dashboard password</label>
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
return trusted(
f"""
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2>
<p class="section-copy">Status: {escape(password_status)}. This protects this management dashboard only. Router update URLs continue to work normally.</p>
</div>
<div class="col-lg-7 d-grid gap-3">
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
{current_password_field}
<div>
<label class="form-label" for="dashboard-password">{escape(password_action)}</label>
<input class="form-control" id="dashboard-password" name="dashboard_password" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div>
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
<input class="form-control" id="dashboard-password-confirm" name="dashboard_password_confirm" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button>
</div>
</form>
{remove_form}
</div>
</div>
</div>
</section>
"""
)
def _render_management_unlock_page(
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
) -> str:
hostname = escape(account["hostname"])
message_html = ""
if message:
message_html = f'<div class="alert alert-warning" role="alert">{escape(message)}</div>'
return _page(
"Unlock KarlDNS management",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Dashboard locked</p>
<h1>{hostname}</h1>
<p class="lead">Enter the dashboard password to manage this hostname.</p>
</div>
</section>
<section class="section">
<div class="container narrow">
{message_html}
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/unlock">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<div>
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password" type="password" autocomplete="current-password" required autofocus>
</div>
<label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
<span class="form-check-label">Stay unlocked for 30 days on this browser</span>
</label>
<button class="btn btn-primary rounded-pill" type="submit">Unlock dashboard</button>
</form>
</div>
</section>
</main>
"""),
)
def _management_domain_claim_panel(
service: DdnsService,
management_slug: str,
csrf_token: str,
challenge: dict[str, str | None] | None,
) -> SafeHtml:
create_claim = trusted(
f"""
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Custom domain</p>
<h2>Claim a domain</h2>
<p class="section-copy">Create a TXT challenge, verify it from this dashboard, then create router credentials for a hostname under that verified domain.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/request" class="d-grid gap-3" aria-label="Create dashboard domain claim">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create domain claim</button>
</form>
</div>
"""
)
if not challenge:
return trusted(
f"""
<section class="section">
<div class="container narrow">{create_claim}</div>
</section>
"""
)
domain = str(challenge["domain"])
claim_secret = str(challenge.get("claim_secret") or "")
verification_name = service.verification_name(domain)
token = str(challenge["token"])
claim_url = service.domain_claim_url(challenge)
status = "Verified" if challenge.get("verified_at") else "Waiting for TXT verification"
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
create_hostname = _management_custom_hostname_form(management_slug, claim_secret, csrf_token) if challenge.get("verified_at") else trusted("")
return trusted(
f"""
<section class="section" id="custom-domain">
<div class="container narrow d-grid gap-4">
{create_claim}
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Domain claim</p>
<h2>{escape(domain)}</h2>
<p class="section-copy">Status: {escape(status)}</p>
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Another dashboard cannot verify or use it.</p>
</div>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The claim secret and private claim link are secrets.</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy TXT record", txt_bundle)}
</div>
<div class="d-grid gap-2">
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Claim secret", claim_secret, "Secret")}
{_copy_row("Private claim link", claim_url, "Secret")}
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/verify" class="d-flex gap-2 flex-wrap" aria-label="Verify dashboard domain claim">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="domain" value="{attr(domain)}">
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<button type="submit" class="btn btn-primary rounded-pill">Verify claim</button>
</form>
{create_hostname}
</div>
</div>
</section>
"""
)
def _management_custom_hostname_form(management_slug: str, claim_secret: str, csrf_token: str | None = None) -> SafeHtml:
csrf_field = f'<input type="hidden" name="csrf" value="{attr(csrf_token)}">' if csrf_token else ""
return trusted(
f"""
<form method="post" action="/m/{attr(management_slug)}/domains/hostnames" class="d-grid gap-3" aria-label="Create verified custom hostname">
{csrf_field}
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<label>Custom hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create custom hostname</button>
</form>
"""
)
def _user_event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
status = escape(event.get("status") or "-")
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
return f"<tr><td>{created}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import escape, trusted
from .templates import _message_band, _page, _top_nav
from .templates_public_sections import _credentials_panel, _custom_domain_flow, _linux_installer_section
def _render_public_home(
service: DdnsService,
settings: DdnsSettings,
created_account: dict[str, str] | None,
message: str | None,
challenge: dict[str, str | None] | None = None,
) -> str:
suffix = escape(settings.hostname_suffix or "your DynDNS domain")
demo_update_url = escape(f"{settings.public_base_url.rstrip('/')}/u/<secret>?myip=<ipaddr>")
created_html = _credentials_panel(service, created_account)
message_html = _message_band(message) if message and not challenge else ""
challenge_html = _custom_domain_flow(service, challenge, message)
return _page(
"KarlDNS",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band">
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.png" alt="" width="80" height="80">
<h1>KarlDNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">Generate one secret update URL under {suffix}. Paste it into your router, script it with curl, or point your own subdomain at it with a CNAME.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<a class="btn btn-primary rounded-pill" href="#create">Generate hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">See curl flow</a>
</div>
</div>
</section>
{message_html}
{created_html}
<section class="section" id="create">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Choose your hostname</p>
<h2>No account. No login.</h2>
<p class="section-copy mx-auto">Use a generated KarlDNS hostname immediately, or verify your own domain with a DNS TXT record.</p>
</div>
<div class="row g-4 align-items-stretch">
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use our domain</p>
<h3>Generate a KarlDNS hostname</h3>
<p class="section-copy mb-0">KarlDNS generates the hostname, secret update URL, and optional router compatibility credentials automatically.</p>
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Generate hostname</button>
</form>
</div>
</div>
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use your own domain</p>
<h3>Verify DNS ownership</h3>
<p class="section-copy mb-0">Add one TXT record so KarlDNS can confirm you control the domain before it publishes A/AAAA records there.</p>
<form method="post" action="/request-domain" class="d-grid gap-3" aria-label="Request a custom domain TXT challenge">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create TXT challenge</button>
</form>
</div>
</div>
</div>
<div class="terminal-card mt-4" aria-hidden="true">
<code>$ curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
<code>good 203.0.113.10</code>
</div>
</div>
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from anything that can send GET.</h2>
<p class="lead mx-auto mt-3">Use FRITZ!Box placeholders, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, or your own automation.</p>
<div class="terminal-card text-start mt-4">
<code># explicit IPv4</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
<code># source IP fallback</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;'</code>
</div>
</div>
</section>
{_linux_installer_section()}
{challenge_html}
</main>
"""),
)
def _render_privacy_page() -> str:
return _page(
"Legal and Privacy",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Legal and privacy</p>
<h1>KarlDNS keeps accountless DynDNS simple.</h1>
<p class="lead">This page explains the basic data handled by this self-hosted service. Configure your own imprint, controller details, and jurisdiction-specific terms before offering it publicly.</p>
</div>
</section>
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores generated hostnames, public DNS target names, update slugs, hashed management tokens, hashed router passwords, DNS TXT verification challenges, IP update history, timestamps, and operational events needed to run Dynamic DNS.</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Private management links, update URLs, router passwords, and one-box update URLs are bearer secrets. Anyone with them can update or manage the related hostname. Passwords and management lookup values are not stored in recoverable plaintext.</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">The service keeps recent update events for troubleshooting and runs cleanup jobs for abandoned setup flows and unused hostnames. Server and reverse-proxy logs may also contain IP addresses and request metadata depending on your deployment.</p>
</div>
<div>
<h2>Contact</h2>
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
</div>
</div>
</section>
</main>
"""),
)
@@ -0,0 +1,191 @@
from __future__ import annotations
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
_copy_button,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_secret_notice,
)
def _linux_installer_section() -> SafeHtml:
system_install = "curl -fsSL https://karldns.de/install.sh | sudo sh"
system_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sudo sh"
user_install = "curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user"
clean_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sh -s -- --mode user --delete-config"
all_commands = "\n".join([system_install, system_uninstall, user_install, clean_uninstall])
return trusted(
f"""
<section class="section" id="linux-installer">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p>
<h2>Install and uninstall should be boring.</h2>
<p class="section-copy mx-auto">For a Linux server, Proxmox host, NAS, or home-lab box that is always on, the installer creates a hostname if needed, installs the updater, schedules cron, runs the first update, and prints the private dashboard URL.</p>
</div>
<div class="card card-body gap-3">
<div class="terminal-card">
<code>{escape(system_install)}</code>
<code>{escape(system_uninstall)}</code>
</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy install", system_install)}
{_copy_button("Copy uninstall", system_uninstall)}
{_copy_button("Copy all commands", all_commands)}
</div>
<div class="alert alert-info my-0 py-2 small" role="note">
Uninstall keeps the private config by default because it may contain your only local copy of the update and dashboard URLs. Use <code>--delete-config</code> for a clean wipe.
</div>
<div>
<h3>Without sudo</h3>
<p class="section-copy">User mode writes to <code>~/.local/bin</code>, <code>~/.config/karldns</code>, and your user crontab.</p>
</div>
<div class="terminal-card">
<code>{escape(user_install)}</code>
<code>{escape(clean_uninstall)}</code>
</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy user install", user_install)}
{_copy_button("Copy clean user uninstall", clean_uninstall)}
</div>
</div>
</div>
</section>
"""
)
def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> SafeHtml:
if not challenge:
return trusted("")
domain = attr(challenge["domain"])
token = str(challenge["token"])
claim_secret = attr(challenge.get("claim_secret") or "")
verification_name = service.verification_name(str(challenge["domain"]))
claim_url = service.domain_claim_url(challenge)
status = escape(message or "Add this TXT record at your DNS provider, then check when it has propagated.")
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
challenge_html = trusted(
f"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3">
<span class="badge text-bg-primary rounded-circle align-self-start">1</span>
<h3>Add the TXT record</h3>
<p class="small text-secondary mb-0 mt-2">{status}</p>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The private claim link is secret.</div>
{_copy_button("Copy TXT record", txt_bundle)}
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Private claim link", claim_url, "Secret")}
<form method="post" action="/verify-domain" class="mt-3" aria-label="Check DNS TXT verification">
<input type="hidden" name="domain" value="{domain}">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<button type="submit" class="btn btn-primary rounded-pill">Check DNS record</button>
</form>
</div>
</div>
"""
)
create_form = _custom_hostname_form(claim_secret) if challenge.get("verified_at") else _locked_custom_hostname_card()
return trusted(
f"""
<section class="section" id="custom-domain">
<div class="container narrow">
<div class="text-center">
<p class="eyebrow">Direct custom-domain publishing</p>
<h2>Verify DNS ownership</h2>
<p class="section-copy mx-auto">Use this only when KarlDNS should publish A/AAAA records directly inside your domain. If you just want your own subdomain, generate a hostname above and create a CNAME to it.</p>
</div>
<div class="row g-3 mt-4 justify-content-center">
{challenge_html}
{create_form}
</div>
</div>
</section>
"""
)
def _credentials_panel(service: DdnsService, created_account: dict[str, str] | None) -> SafeHtml:
if not created_account:
return trusted("")
hostname = created_account["hostname"]
username = created_account["username"]
password = created_account["password"]
update_url = service.fritz_update_url(created_account)
credentialed_update_url = service.credentialed_update_url(created_account)
management_url = service.magic_management_url(created_account)
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, password, management_url)
manual_commands = _manual_update_commands(update_url)
return trusted(
f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Hostname generated</p>
<h2>{escape(hostname)}</h2>
<p class="section-copy">Use the Update-URL directly. The separate Benutzername and Kennwort values are optional compatibility fields for routers that require them.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap my-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy one-box update URL", credentialed_update_url)}
{_copy_button("Copy CNAME record", cname_record)}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("One-box update URL:", credentialed_update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", password, "Secret")}
{_copy_row("Private status page:", management_url, "Secret")}
</div>
{_manual_update_panel(manual_commands)}
</div>
</section>
"""
)
def _custom_hostname_form(claim_secret: str) -> SafeHtml:
return trusted(
f"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create router hostname</h3>
<form method="post" action="/accounts" class="d-grid gap-3" aria-label="Create custom-domain router credentials">
<input type="hidden" name="mode" value="custom">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<label>Verified hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<p class="section-copy mb-0">Router compatibility credentials are generated automatically after creation.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create credentials</button>
</form>
</div>
</div>
"""
)
def _locked_custom_hostname_card() -> SafeHtml:
return trusted(
"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3 opacity-75">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create router hostname</h3>
<p class="small text-secondary mb-0 mt-2">This unlocks after the TXT record is visible in public DNS.</p>
</div>
</div>
"""
)
+247
View File
@@ -0,0 +1,247 @@
#!/bin/sh
set -eu
KARLDNS_BASE_URL="${KARLDNS_BASE_URL:-https://karldns.de}"
KARLDNS_INTERVAL_MINUTES="${KARLDNS_INTERVAL_MINUTES:-5}"
KARLDNS_INSTALL_MODE="${KARLDNS_INSTALL_MODE:-auto}"
KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL:-}"
KARLDNS_HOSTNAME="${KARLDNS_HOSTNAME:-}"
KARLDNS_MANAGEMENT_URL="${KARLDNS_MANAGEMENT_URL:-}"
KARLDNS_RAW_BASE_URL="${KARLDNS_RAW_BASE_URL:-https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts}"
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
KARLDNS_SKIP_CRON="${KARLDNS_SKIP_CRON:-0}"
KARLDNS_SKIP_FIRST_UPDATE="${KARLDNS_SKIP_FIRST_UPDATE:-0}"
usage() {
cat <<'EOF'
KarlDNS Linux installer
Usage:
install-linux.sh [options]
Options:
--base-url URL KarlDNS server URL. Default: https://karldns.de
--raw-base-url URL Script download base URL. Default: GitHub raw main branch
--every MINUTES Cron interval, 1-60. Default: 5
--mode MODE auto, system, or user. Default: auto
--update-url URL Existing private /u/<secret> update URL
--hostname NAME Optional display hostname for output/config
--dashboard-url URL Optional private dashboard URL for output/config
--ipv4 VALUE auto, off, or a static IPv4. Default: auto
--ipv6 VALUE auto, off, or a static IPv6. Default: auto
--no-cron Install files only, do not configure cron
--no-run Do not run the first update after install
-h, --help Show this help
Examples:
curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sudo sh
curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sh -s -- --mode user
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--base-url) KARLDNS_BASE_URL="${2:?missing value for --base-url}"; shift 2 ;;
--raw-base-url) KARLDNS_RAW_BASE_URL="${2:?missing value for --raw-base-url}"; shift 2 ;;
--every) KARLDNS_INTERVAL_MINUTES="${2:?missing value for --every}"; shift 2 ;;
--mode) KARLDNS_INSTALL_MODE="${2:?missing value for --mode}"; shift 2 ;;
--update-url) KARLDNS_UPDATE_URL="${2:?missing value for --update-url}"; shift 2 ;;
--hostname) KARLDNS_HOSTNAME="${2:?missing value for --hostname}"; shift 2 ;;
--dashboard-url|--management-url) KARLDNS_MANAGEMENT_URL="${2:?missing value for --dashboard-url}"; shift 2 ;;
--ipv4) KARLDNS_IPV4="${2:?missing value for --ipv4}"; shift 2 ;;
--ipv6) KARLDNS_IPV6="${2:?missing value for --ipv6}"; shift 2 ;;
--no-cron) KARLDNS_SKIP_CRON=1; shift ;;
--no-run) KARLDNS_SKIP_FIRST_UPDATE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "KarlDNS installer: unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
done
need() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "KarlDNS installer: missing required command: $1" >&2
exit 1
fi
}
json_field() {
key="$1"
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import json,sys; print(json.load(sys.stdin).get(sys.argv[1], ""))' "$key"
else
sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n 1
fi
}
quote_sh() {
printf "%s" "$1" | sed "s/'/'\\\\''/g; 1s/^/'/; \$s/\$/'/"
}
download_updater() {
target_path="$1"
tmp_path="$(mktemp)"
if ! "$curl_bin" -fsS "$KARLDNS_RAW_BASE_URL/karldns-update.sh" -o "$tmp_path"; then
rm -f "$tmp_path"
exit 1
fi
chmod 755 "$tmp_path"
mv "$tmp_path" "$target_path"
}
cron_schedule() {
if [ "$KARLDNS_INTERVAL_MINUTES" = "60" ]; then
printf '0 * * * *'
else
printf '*/%s * * * *' "$KARLDNS_INTERVAL_MINUTES"
fi
}
update_crontab() {
cron_file="$1"
schedule="$2"
command="$3"
marker_start="# karldns-update start"
marker_end="# karldns-update end"
awk -v start="$marker_start" -v end="$marker_end" '
$0 == start {skip = 1; next}
$0 == end {skip = 0; next}
skip != 1 {print}
' "$cron_file" 2>/dev/null
printf '%s\n' "$marker_start"
printf '%s %s\n' "$schedule" "$command"
printf '%s\n' "$marker_end"
}
write_config() {
config_file="$1"
cache_file="$2"
lock_file="$3"
umask 077
{
printf 'KARLDNS_BASE_URL=%s\n' "$(quote_sh "$KARLDNS_BASE_URL")"
printf 'KARLDNS_UPDATE_URL=%s\n' "$(quote_sh "$KARLDNS_UPDATE_URL")"
printf 'KARLDNS_HOSTNAME=%s\n' "$(quote_sh "$KARLDNS_HOSTNAME")"
printf 'KARLDNS_MANAGEMENT_URL=%s\n' "$(quote_sh "$KARLDNS_MANAGEMENT_URL")"
printf 'KARLDNS_CACHE_FILE=%s\n' "$(quote_sh "$cache_file")"
printf 'KARLDNS_LOCK_FILE=%s\n' "$(quote_sh "$lock_file")"
printf 'KARLDNS_IPV4=%s\n' "$(quote_sh "$KARLDNS_IPV4")"
printf 'KARLDNS_IPV6=%s\n' "$(quote_sh "$KARLDNS_IPV6")"
} > "$config_file"
chmod 600 "$config_file"
}
install_user() {
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/karldns"
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/karldns"
bin_dir="${HOME}/.local/bin"
config_file="${config_dir}/updater.env"
updater_path="${bin_dir}/karldns-update"
cron_file="$(mktemp)"
mkdir -p "$config_dir" "$cache_dir" "$bin_dir"
download_updater "$updater_path"
chmod 700 "$updater_path"
write_config "$config_file" "$cache_dir/last-ip" "$cache_dir/updater.lock"
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
need crontab
crontab -l > "$cron_file" 2>/dev/null || true
update_crontab "$cron_file" "$(cron_schedule)" \
"KARLDNS_CONFIG=$(quote_sh "$config_file") $(quote_sh "$updater_path") >/dev/null 2>&1" > "$cron_file.new"
mv "$cron_file.new" "$cron_file"
crontab "$cron_file"
rm -f "$cron_file"
fi
}
install_system() {
config_dir="/etc/karldns"
cache_dir="/var/cache/karldns"
config_file="${config_dir}/updater.env"
updater_path="/usr/local/bin/karldns-update"
cron_file="/etc/cron.d/karldns-updater"
mkdir -p "$config_dir" "$cache_dir"
download_updater "$updater_path"
write_config "$config_file" "$cache_dir/last-ip" "/run/karldns-updater.lock"
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
cat > "$cron_file" <<EOF
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$(cron_schedule) root KARLDNS_CONFIG=$(quote_sh "$config_file") $(quote_sh "$updater_path") >/dev/null 2>&1
EOF
chmod 644 "$cron_file"
fi
}
need curl
curl_bin="$(command -v curl)"
case "$KARLDNS_INTERVAL_MINUTES" in
''|*[!0-9]*) echo "KARLDNS_INTERVAL_MINUTES must be a positive integer" >&2; exit 1 ;;
esac
if [ "$KARLDNS_INTERVAL_MINUTES" -lt 1 ] || [ "$KARLDNS_INTERVAL_MINUTES" -gt 60 ]; then
echo "KARLDNS_INTERVAL_MINUTES must be between 1 and 60" >&2
exit 1
fi
case "$KARLDNS_INSTALL_MODE" in
auto)
if [ "$(id -u)" -eq 0 ]; then
KARLDNS_INSTALL_MODE="system"
else
KARLDNS_INSTALL_MODE="user"
fi
;;
user|system) ;;
*) echo "KARLDNS_INSTALL_MODE must be auto, user, or system" >&2; exit 1 ;;
esac
if [ "$KARLDNS_INSTALL_MODE" = "system" ] && [ "$(id -u)" -ne 0 ]; then
echo "System install needs root. Re-run with sudo or set KARLDNS_INSTALL_MODE=user." >&2
exit 1
fi
if [ -z "$KARLDNS_UPDATE_URL" ]; then
echo "KarlDNS: creating a new hostname at $KARLDNS_BASE_URL ..."
response="$("$curl_bin" -fsS --max-time 30 -H 'content-type: application/json' -d '{}' "$KARLDNS_BASE_URL/api/v1/hostnames/magic")"
KARLDNS_UPDATE_URL="$(printf '%s' "$response" | json_field update_url)"
KARLDNS_HOSTNAME="$(printf '%s' "$response" | json_field hostname)"
KARLDNS_MANAGEMENT_URL="$(printf '%s' "$response" | json_field management_url)"
fi
if [ -z "$KARLDNS_UPDATE_URL" ]; then
echo "KarlDNS installer: could not get an update URL" >&2
exit 1
fi
case "$KARLDNS_UPDATE_URL" in
*"<ipaddr>"*|*"<ip6addr>"*) KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL%%\?*}" ;;
esac
if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
install_system
else
install_user
fi
echo "KarlDNS installed."
echo "Hostname: ${KARLDNS_HOSTNAME:-already configured}"
echo "Dashboard: ${KARLDNS_MANAGEMENT_URL:-use your existing private status page}"
echo "Mode: $KARLDNS_INSTALL_MODE"
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
echo "Interval: every $KARLDNS_INTERVAL_MINUTES minute(s)"
else
echo "Cron: skipped"
fi
if [ "$KARLDNS_SKIP_FIRST_UPDATE" = "0" ]; then
echo "Running first update now..."
if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
KARLDNS_CONFIG=/etc/karldns/updater.env /usr/local/bin/karldns-update
else
KARLDNS_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/karldns/updater.env" "$HOME/.local/bin/karldns-update"
fi
fi
+93
View File
@@ -0,0 +1,93 @@
#!/bin/sh
set -eu
CONFIG_FILE="${KARLDNS_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/karldns/updater.env}"
if [ -r "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
fi
: "${KARLDNS_UPDATE_URL:?KARLDNS_UPDATE_URL is required in $CONFIG_FILE or the environment}"
KARLDNS_BASE_URL="${KARLDNS_BASE_URL:-https://karldns.de}"
KARLDNS_LOCK_FILE="${KARLDNS_LOCK_FILE:-/tmp/karldns-updater.lock}"
KARLDNS_CACHE_FILE="${KARLDNS_CACHE_FILE:-${XDG_CACHE_HOME:-$HOME/.cache}/karldns/last-ip}"
KARLDNS_CURL="${KARLDNS_CURL:-curl}"
KARLDNS_IP_URL="${KARLDNS_IP_URL:-$KARLDNS_BASE_URL/ip}"
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
case "$KARLDNS_UPDATE_URL" in
*"<ipaddr>"*|*"<ip6addr>"*) KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL%%\?*}" ;;
esac
mkdir -p "$(dirname "$KARLDNS_CACHE_FILE")"
mkdir -p "$(dirname "$KARLDNS_LOCK_FILE")" 2>/dev/null || true
if command -v flock >/dev/null 2>&1; then
exec 9>"$KARLDNS_LOCK_FILE"
if ! flock -n 9; then
exit 0
fi
else
lock_dir="${KARLDNS_LOCK_FILE}.d"
if ! mkdir "$lock_dir" 2>/dev/null; then
exit 0
fi
trap 'rmdir "$lock_dir"' EXIT INT TERM
fi
ipv4=""
ipv6=""
if [ "$KARLDNS_IPV4" = "auto" ]; then
ipv4="$("$KARLDNS_CURL" -4 -fsS --max-time 15 "$KARLDNS_IP_URL" 2>/dev/null | tr -d '[:space:]' || true)"
elif [ "$KARLDNS_IPV4" != "off" ]; then
ipv4="$KARLDNS_IPV4"
fi
if [ "$KARLDNS_IPV6" = "auto" ]; then
ipv6="$("$KARLDNS_CURL" -6 -fsS --max-time 15 "$KARLDNS_IP_URL" 2>/dev/null | tr -d '[:space:]' || true)"
elif [ "$KARLDNS_IPV6" != "off" ]; then
ipv6="$KARLDNS_IPV6"
fi
case "$ipv4" in *" "*) echo "KarlDNS: invalid IPv4 discovery result" >&2; exit 1 ;; esac
case "$ipv6" in *" "*) echo "KarlDNS: invalid IPv6 discovery result" >&2; exit 1 ;; esac
public_ip="${ipv4}|${ipv6}"
has_discovered_ip=0
if [ -n "$ipv4" ] || [ -n "$ipv6" ]; then
has_discovered_ip=1
fi
last_ip=""
if [ -r "$KARLDNS_CACHE_FILE" ]; then
last_ip="$(cat "$KARLDNS_CACHE_FILE")"
fi
if [ "$has_discovered_ip" = "1" ] && [ "$public_ip" = "$last_ip" ] && [ "${KARLDNS_FORCE:-0}" != "1" ]; then
echo "KarlDNS: unchanged ${ipv4:-no-ipv4} ${ipv6:-no-ipv6}"
exit 0
fi
if [ "$has_discovered_ip" = "1" ]; then
set -- --get "$KARLDNS_UPDATE_URL"
if [ -n "$ipv4" ]; then
set -- "$@" --data-urlencode "myip=$ipv4"
fi
if [ -n "$ipv6" ]; then
set -- "$@" --data-urlencode "myipv6=$ipv6"
fi
response="$("$KARLDNS_CURL" -fsS --max-time 20 "$@")"
else
response="$("$KARLDNS_CURL" -fsS --max-time 20 "$KARLDNS_UPDATE_URL")"
fi
case "$response" in
good*|nochg*)
if [ "$has_discovered_ip" = "1" ]; then
printf '%s\n' "$public_ip" > "$KARLDNS_CACHE_FILE"
fi
echo "KarlDNS: $response"
;;
*)
echo "KarlDNS: update failed: $response" >&2
exit 1
;;
esac
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
set -eu
KARLDNS_INSTALL_MODE="${KARLDNS_INSTALL_MODE:-auto}"
KARLDNS_DELETE_CONFIG="${KARLDNS_DELETE_CONFIG:-0}"
usage() {
cat <<'EOF'
KarlDNS Linux uninstaller
Usage:
uninstall-linux.sh [options]
Options:
--mode MODE auto, system, or user. Default: auto
--delete-config Also delete the private updater config and last-IP cache
-h, --help Show this help
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--mode) KARLDNS_INSTALL_MODE="${2:?missing value for --mode}"; shift 2 ;;
--delete-config) KARLDNS_DELETE_CONFIG=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "KarlDNS uninstaller: unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
done
remove_user() {
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/karldns"
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/karldns"
updater_path="${HOME}/.local/bin/karldns-update"
cron_file="$(mktemp)"
if command -v crontab >/dev/null 2>&1; then
crontab -l 2>/dev/null | awk '
$0 == "# karldns-update start" {skip = 1; next}
$0 == "# karldns-update end" {skip = 0; next}
skip != 1 {print}
' > "$cron_file" || true
crontab "$cron_file"
fi
rm -f "$cron_file" "$updater_path"
if [ "$KARLDNS_DELETE_CONFIG" = "1" ]; then
rm -rf "$config_dir" "$cache_dir"
fi
}
remove_system() {
if [ "$(id -u)" -ne 0 ]; then
echo "System uninstall needs root. Re-run with sudo or set --mode user." >&2
exit 1
fi
rm -f /etc/cron.d/karldns-updater /usr/local/bin/karldns-update
if [ "$KARLDNS_DELETE_CONFIG" = "1" ]; then
rm -rf /etc/karldns /var/cache/karldns
fi
}
case "$KARLDNS_INSTALL_MODE" in
auto)
if [ "$(id -u)" -eq 0 ]; then
KARLDNS_INSTALL_MODE="system"
else
KARLDNS_INSTALL_MODE="user"
fi
;;
user|system) ;;
*) echo "KARLDNS_INSTALL_MODE must be auto, user, or system" >&2; exit 1 ;;
esac
if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
remove_system
else
remove_user
fi
echo "KarlDNS uninstalled from $KARLDNS_INSTALL_MODE mode."
if [ "$KARLDNS_DELETE_CONFIG" = "0" ]; then
echo "Private config was kept. Re-run with --delete-config to remove it."
fi
+221
View File
@@ -0,0 +1,221 @@
from pathlib import Path
from fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
from router_dyndns.ddns_security import admin_csrf_token
def test_shared_secret_is_not_admin_password_fallback(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
client = TestClient(app)
response = client.get("/admin", auth=("admin", "secret"))
assert response.status_code == 500
assert response.json()["detail"] == "DDNS_ADMIN_PASSWORD is required"
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_domain_challenge("example.net")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get("/admin", auth=("admin", "admin"))
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert "Operator console" in response.text
assert "Generate credentials" not in response.text
assert 'action="/admin/accounts"' not in response.text
assert response.text.count("data-admin-table") == 4
assert response.text.count("data-table-search") == 4
assert response.text.count("data-table-page-size") == 4
assert response.text.count("data-table-pagination") == 4
assert "admin-table-scroll" in response.text
assert "Scheduled cleanup" in response.text
assert "Scheduler state" in response.text
assert "database lease" in response.text
assert "Active credentials" in response.text
assert "Domain claims" in response.text
assert "Recent events" in response.text
assert "example.net" in response.text
def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_path="/ops",
)
store = DdnsStore(settings.database_path)
store.create_account("home.example.net", "router")
app = make_app(settings)
client = TestClient(app)
response = client.get("/ops", auth=("admin", "admin"))
assert response.status_code == 200
assert 'href="/ops/records"' in response.text
assert 'href="/ops/events"' in response.text
assert 'action="/ops/cleanup"' in response.text
assert 'action="/ops/accounts/rotate"' in response.text
assert 'action="/admin/cleanup"' not in response.text
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 200
assert client.get("/records", auth=("admin", "admin")).status_code == 404
assert client.get("/admin", auth=("admin", "admin")).status_code == 404
def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
first = store.create_account("first.example.net", "router")
second = store.create_account("second.example.net", "router")
store.upsert(first["hostname"], "203.0.113.1", None)
store.upsert(second["hostname"], "203.0.113.2", None)
store.log_update_event(first["hostname"], "203.0.113.1", None, "updated", "ok", "198.51.100.1")
store.log_update_event(second["hostname"], "203.0.113.2", None, "updated", "ok", "198.51.100.2")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
records = client.get("/records?limit=1&offset=1", auth=("admin", "admin")).json()
events = client.get("/events?limit=1&offset=1", auth=("admin", "admin")).json()
assert records["limit"] == 1
assert records["offset"] == 1
assert records["total"] == 2
assert len(records["items"]) == 1
assert events["limit"] == 1
assert events["offset"] == 1
assert events["total"] == 2
assert len(events["items"]) == 1
def test_admin_posts_require_csrf(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.post(
"/admin/cleanup",
auth=("admin", "admin"),
data={},
)
assert response.status_code == 403
def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/admin", params={"password": "admin"})
assert response.status_code == 401
def test_admin_routes_are_rate_limited(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.get("/admin", auth=("admin", "wrong"))
second = client.get("/admin", auth=("admin", "wrong"))
assert first.status_code == 401
assert second.status_code == 429
def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
store = DdnsStore(settings.database_path)
account = store.create_account("home.example.net", "router")
app = make_app(settings)
client = TestClient(app)
csrf = admin_csrf_token(settings)
update = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
)
assert update.status_code == 200
assert "Update URL rotated" in update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
management = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
)
assert management.status_code == 200
assert "Private status page rotated" in management.text
assert "/m/" in management.text
password = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
)
assert password.status_code == 200
assert "Router password rotated" in password.text
assert "One-box update URL:" in password.text
assert "/nic/update?" in password.text
assert "Kennwort (optional):" in password.text
statuses = {event["status"] for event in store.list_update_events_for_hostname("home.example.net", 10)}
assert "admin_update_url_rotated" in statuses
assert "admin_management_url_rotated" in statuses
assert "admin_router_credentials_rotated" in statuses
def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -> None:
settings = DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")
store = DdnsStore(settings.database_path)
store.create_account("home.example.net", "router")
app = make_app(settings)
client = TestClient(app)
response = client.post(
"/admin/accounts/delete",
auth=("admin", "admin"),
data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "},
follow_redirects=False,
)
assert response.status_code == 303
assert store.get_account_by_hostname("home.example.net") is None
events = store.list_update_events_for_hostname("home.example.net", 10)
assert events[0]["status"] == "admin_hostname_deleted"
assert "secret" not in str(events[0]["detail"]).lower()
def test_admin_template_escapes_update_event_html(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_account("home.example.net", "router")
store.log_update_event(
"home.example.net",
"203.0.113.10",
None,
"dns_failed",
'<script>alert("xss")</script>',
"198.51.100.20",
)
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get("/admin", auth=("admin", "admin"))
assert response.status_code == 200
assert '<script>alert("xss")</script>' not in response.text
assert "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;" in response.text
+351
View File
@@ -0,0 +1,351 @@
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert response.status_code == 201
payload = response.json()
one_box_url = payload["credentialed_update_url"]
parsed = urlparse(one_box_url)
params = parse_qs(parsed.query)
assert parsed.path == "/nic/update"
assert params["hostname"] == [payload["hostname"]]
assert params["username"] == [payload["username"]]
assert params["password"] == [payload["password"]]
assert params["myip"] == ["<ipaddr>"]
assert params["myipv6"] == ["<ip6addr>"]
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
docs = client.get("/docs")
redoc = client.get("/redoc")
schema = client.get("/openapi.json").json()
assert docs.status_code == 200
assert redoc.status_code == 200
assert "unsafe-inline" in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
assert "/api/v1/hostnames/magic" in schema["paths"]
assert "/api/v1/updates/{update_slug}" in schema["paths"]
assert "/u/{update_slug}" in schema["paths"]
assert "/nic/update" in schema["paths"]
assert "/ip" in schema["paths"]
assert "/healthz" in schema["paths"]
assert "/admin" not in schema["paths"]
assert "/records" not in schema["paths"]
assert "/events" not in schema["paths"]
assert "/" not in schema["paths"]
assert "/magic" not in schema["paths"]
assert "/request-domain" not in schema["paths"]
assert "/verify-domain" not in schema["paths"]
assert "/accounts" not in schema["paths"]
assert "/m/{management_slug}" not in schema["paths"]
assert "/d/{claim_secret}" not in schema["paths"]
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
assert all(tag["name"] != "admin" for tag in schema["tags"])
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
assert "Dashboard passwords protect only the web dashboard" in management_tag["description"]
management_get = schema["paths"]["/api/v1/management/{management_slug}"]["get"]
management_delete = schema["paths"]["/api/v1/management/{management_slug}"]["delete"]
assert "Dashboard passwords protect only the web dashboard" in management_get["description"]
assert "Dashboard passwords protect only the web dashboard" in management_delete["description"]
def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_path="/ops",
)
)
client = TestClient(app)
redoc = client.get("/redoc")
schema = client.get("/openapi.json").json()
forbidden_paths = {
"/ops",
"/ops/accounts/delete",
"/ops/accounts/rotate",
"/ops/cleanup",
"/ops/events",
"/ops/records",
"/records",
"/events",
}
assert redoc.status_code == 200
assert forbidden_paths.isdisjoint(schema["paths"])
assert all("admin" not in tag["name"].lower() for tag in schema["tags"])
def test_public_redoc_only_exposes_api_and_router_endpoints(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
schema = client.get("/openapi.json").json()
expected_public_paths = {
"/api/v1/domains/challenges",
"/api/v1/domains/verify",
"/api/v1/hostnames/custom",
"/api/v1/hostnames/magic",
"/api/v1/management/{management_slug}",
"/api/v1/updates/{update_slug}",
"/healthz",
"/ip",
"/nic/update",
"/u/{update_slug}",
}
assert set(schema["paths"]) == expected_public_paths
def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
schema = client.get("/openapi.json").json()
credentials = schema["components"]["schemas"]["HostnameCredentials"]["properties"]
managed = schema["components"]["schemas"]["ManagedRecord"]["properties"]
for name in ["password", "update_url", "credentialed_update_url", "management_url"]:
assert "Secret" in credentials[name]["description"]
assert "Secret" in managed["update_url"]["description"]
@pytest.mark.parametrize(
("path", "payload"),
[
("/api/v1/hostnames/magic", {"username": "router", "unexpected": True}),
("/api/v1/domains/challenges", {"domain": "example.net", "unexpected": True}),
("/api/v1/domains/verify", {"domain": "example.net", "claim_secret": "secret", "unexpected": True}),
(
"/api/v1/hostnames/custom",
{"hostname": "home.example.net", "claim_secret": "secret", "unexpected": True},
),
],
)
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.post(path, json=payload)
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "extra_forbidden"
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
body = created.json()
assert body["hostname"].endswith(".ddns.example.net")
assert body["update_url"].startswith("http://ddns.example.net/u/")
assert body["management_url"].startswith("http://ddns.example.net/m/")
management_slug = body["management_url"].rsplit("/", 1)[1]
managed = client.get(f"/api/v1/management/{management_slug}")
assert managed.status_code == 200
assert managed.json()["hostname"] == body["hostname"]
deleted = client.delete(f"/api/v1/management/{management_slug}")
assert deleted.status_code == 204
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
assert response.status_code == 200
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
assert len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypatch) -> None:
def fail_publish(result, settings):
raise DnsProviderError("dns down")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
assert response.status_code == 500
assert store.list_records() == []
assert store.list_update_events()[0]["status"] == "dns_failed"
def test_api_custom_hostname_requires_verified_domain(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
assert challenge.status_code == 201
challenge_body = challenge.json()
verified = client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge_body["claim_secret"]},
)
assert verified.status_code == 200
assert verified.json()["verified"] is True
hostname = client.post(
"/api/v1/hostnames/custom",
json={
"hostname": "home.example.net",
"claim_secret": challenge_body["claim_secret"],
"username": "router",
},
)
assert hostname.status_code == 201
assert hostname.json()["hostname"] == "home.example.net"
def test_domain_challenge_verification_requires_current_secret_and_owner(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
first = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
second = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
assert store.verify_domain_challenge("example.net", first["claim_secret"], owner_hostname="owner.example.net") is None
assert store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="other.example.net") is None
assert store.get_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")["verified_at"] is None
verified = store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")
assert verified is not None
assert verified["verified_at"]
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert first.status_code == 201
assert second.status_code == 429
def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
assert response.status_code == 400
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
assert store.list_records() == []
def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
verified = client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
)
overwrite = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
assert verified.status_code == 200
assert overwrite.status_code == 409
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
dns_zones={"ddns.example.net"},
)
)
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
)
hostname = client.post(
"/api/v1/hostnames/custom",
json={"hostname": "home.example.net", "claim_secret": challenge["claim_secret"]},
)
assert hostname.status_code == 403
assert hostname.json()["detail"] == "hostname is outside configured DNS publishing zones"
-976
View File
@@ -1,976 +0,0 @@
import re
import time
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
from router_dyndns.ddns_security import admin_csrf_token
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
shared_secret="secret",
allowed_hosts={"home.example.net"},
)
)
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "home.example.net",
"myip": "203.0.113.8",
"myipv6": "2001:db8::8",
"password": "secret",
},
)
assert response.status_code == 200
assert response.text == "good 203.0.113.8"
records = client.get("/records", auth=("admin", "secret")).json()
assert records[0]["hostname"] == "home.example.net"
assert records[0]["ipv4"] == "203.0.113.8"
assert records[0]["ipv6"] == "2001:db8::8"
def test_update_rejects_wrong_secret(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "home.example.net", "myip": "203.0.113.8", "password": "wrong"},
)
assert response.status_code == 401
def test_update_rejects_unlisted_host(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
shared_secret="secret",
allowed_hosts={"home.example.net"},
)
)
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "other.example.net", "myip": "203.0.113.8", "password": "secret"},
)
assert response.status_code == 403
def test_generated_account_can_update_own_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "home.example.net",
"myip": "203.0.113.9",
"username": account["username"],
"password": account["password"],
},
)
assert response.status_code == 200
assert response.text == "good 203.0.113.9"
slug_response = client.get(
f"/u/{account['update_slug']}",
params={"myip": "203.0.113.10"},
)
assert slug_response.status_code == 200
assert slug_response.text == "good 203.0.113.10"
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert response.status_code == 201
payload = response.json()
one_box_url = payload["credentialed_update_url"]
parsed = urlparse(one_box_url)
params = parse_qs(parsed.query)
assert parsed.path == "/nic/update"
assert params["hostname"] == [payload["hostname"]]
assert params["username"] == [payload["username"]]
assert params["password"] == [payload["password"]]
assert params["myip"] == ["<ipaddr>"]
assert params["myipv6"] == ["<ip6addr>"]
def test_one_box_update_url_can_authenticate_with_query_credentials(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
settings = DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
app = make_app(settings)
client = TestClient(app)
one_box_url = ddns_service.DdnsService(settings, store).credentialed_update_url(account)
parsed = urlparse(one_box_url)
params = parse_qs(parsed.query)
params["myip"] = ["203.0.113.11"]
response = client.get(parsed.path, params={key: value[0] for key, value in params.items()})
assert response.status_code == 200
assert response.text == "good 203.0.113.11"
assert response.headers["cache-control"] == "no-store"
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_account("public-target.ddns.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "public-target.ddns.example.net",
"myip": "203.0.113.9",
},
)
assert response.status_code == 401
assert store.list_records() == []
def test_dns_publish_failure_does_not_update_record(tmp_path: Path, monkeypatch) -> None:
def fail_publish(result, settings):
raise RuntimeError("dns down")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 500
assert store.list_records() == []
events = store.list_update_events()
assert events[0]["status"] == "dns_failed"
def test_dns_publish_success_updates_record(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 200
assert response.text == "good 203.0.113.9"
assert len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.9"
def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "other.example.net",
"myip": "203.0.113.9",
"username": account["username"],
"password": account["password"],
},
)
assert response.status_code == 401
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_domain_challenge("example.net")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get("/admin", auth=("admin", "admin"))
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert "Operator console" in response.text
assert "Generate credentials" not in response.text
assert 'action="/admin/accounts"' not in response.text
assert response.text.count("data-admin-table") == 4
assert response.text.count("data-table-search") == 4
assert response.text.count("data-table-page-size") == 4
assert response.text.count("data-table-pagination") == 4
assert "admin-table-scroll" in response.text
assert "Scheduled cleanup" in response.text
assert "Active credentials" in response.text
assert "Domain claims" in response.text
assert "Recent events" in response.text
assert "example.net" in response.text
def test_admin_posts_require_csrf(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.post(
"/admin/cleanup",
auth=("admin", "admin"),
data={},
)
assert response.status_code == 403
def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/admin", params={"password": "admin"})
assert response.status_code == 401
def test_admin_routes_are_rate_limited(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.get("/admin", auth=("admin", "wrong"))
second = client.get("/admin", auth=("admin", "wrong"))
assert first.status_code == 401
assert second.status_code == 429
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.get("/m/not-a-real-token")
second = client.get("/m/another-token")
assert first.status_code == 404
assert second.status_code == 429
def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
store = DdnsStore(settings.database_path)
account = store.create_account("home.example.net", "router")
app = make_app(settings)
client = TestClient(app)
csrf = admin_csrf_token(settings)
update = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
)
assert update.status_code == 200
assert "Update URL rotated" in update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
management = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
)
assert management.status_code == 200
assert "Private status page rotated" in management.text
assert "/m/" in management.text
password = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
)
assert password.status_code == 200
assert "Router password rotated" in password.text
assert "One-box update URL:" in password.text
assert "/nic/update?" in password.text
assert "Kennwort (optional):" in password.text
def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "Manual updates" in response.text
assert "curl -sS" in response.text
assert "203.0.113.10" in response.text
assert "2001:db8::10" in response.text
assert "Public:" in response.text
assert "Secret:" in response.text
assert ".ddns.example.net" in response.text
assert "/u/" in response.text
assert "/nic/update?" in response.text
assert "/m/" in response.text
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/magic")
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Private status page:" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "FRITZ!Box replaces" in response.text
assert "Copy curl examples" in response.text
assert response.text.count("Secret") >= 3
assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text
def test_magic_get_redirects_to_home_form(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/magic", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/#create"
def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
management = client.get(f"/m/{account['management_slug']}")
assert management.status_code == 200
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Copy complete FRITZ!Box fields" in management.text
assert "Copy CNAME record" in management.text
assert "Manual updates" in management.text
assert "curl -sS" in management.text
assert "Public:" in management.text
assert "Secret:" in management.text
assert "Router updates" in management.text
deleted = client.post(f"/m/{account['management_slug']}/delete")
assert deleted.status_code == 200
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 404
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
store.log_update_event("home.ddns.example.net", "203.0.113.9", None, "updated", "ok", "198.51.100.1")
store.log_update_event("home.ddns.example.net", "203.0.113.10", None, "updated", "ok", "198.51.100.1")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/m/{account['management_slug']}")
assert response.status_code == 200
assert "Router updates" in response.text
assert "203.0.113.9" in response.text
assert "203.0.113.10" in response.text
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
assert response.status_code == 403
def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
store = DdnsStore(tmp_path / "ddns.sqlite3")
challenge_data = store.create_domain_challenge("example.net")
verify = client.post(
"/verify-domain",
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
)
assert verify.status_code == 200
response = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
)
assert response.status_code == 200
assert "home.example.net" in response.text
assert "Update-URL:" in response.text
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
challenge = client.post("/request-domain", data={"domain": "example.net"})
assert challenge.status_code == 200
assert "Private claim link" in challenge.text
assert "/d/" in challenge.text
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
claim_page = client.get(f"/d/{claim_secret}")
assert claim_page.status_code == 200
assert "TXT value" in claim_page.text
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
assert verify.status_code == 200
assert "Create credentials" in verify.text
created = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
docs = client.get("/docs")
redoc = client.get("/redoc")
schema = client.get("/openapi.json").json()
assert docs.status_code == 200
assert redoc.status_code == 200
assert "unsafe-inline" in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
assert "/api/v1/hostnames/magic" in schema["paths"]
assert "/api/v1/updates/{update_slug}" in schema["paths"]
assert "/admin" not in schema["paths"]
assert "/records" not in schema["paths"]
assert "/events" not in schema["paths"]
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
assert all(tag["name"] != "admin" for tag in schema["tags"])
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/admin"' not in response.text
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "generates the hostname, secret update URL, and optional router compatibility credentials automatically" in response.text
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="karldns.de",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "https://karldns.de/u/&lt;secret&gt;" in response.text
assert "http://127.0.0.1" not in response.text
assert "http://localhost" not in response.text
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert '<script src="/app.js" defer></script>' in response.text
assert "script-src 'self'" in response.headers["content-security-policy"]
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert client.get("/app.js").status_code == 200
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "Made with ❤️ by Karl" in response.text
assert 'href="https://github.com/StasonJatham/router-dyndns"' in response.text
assert 'href="https://karl.fail"' in response.text
assert 'href="https://karlcom.de"' in response.text
assert 'href="/privacy"' in response.text
privacy = client.get("/privacy")
assert privacy.status_code == 200
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
assert "hashed router passwords" in privacy.text
assert "Made with ❤️ by Karl" in privacy.text
def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/favicon.ico"' in response.text
assert 'href="/favicon-32x32.png"' in response.text
assert 'href="/favicon-16x16.png"' in response.text
assert 'href="/apple-touch-icon.png"' in response.text
assert 'href="/site.webmanifest"' in response.text
for path in [
"/favicon.ico",
"/favicon-32x32.png",
"/favicon-16x16.png",
"/apple-touch-icon.png",
"/logo-192.png",
"/logo-512.png",
]:
asset = client.get(path)
assert asset.status_code == 200
assert asset.content
manifest = client.get("/site.webmanifest")
assert manifest.status_code == 200
assert manifest.json()["icons"] == [
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"},
]
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
trusted_hosts={"ddns.example.net"},
)
)
client = TestClient(app)
response = client.get("/", headers={"host": "evil.example.net"})
assert response.status_code == 400
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
body = created.json()
assert body["hostname"].endswith(".ddns.example.net")
assert body["update_url"].startswith("http://ddns.example.net/u/")
assert body["management_url"].startswith("http://ddns.example.net/m/")
management_slug = body["management_url"].rsplit("/", 1)[1]
managed = client.get(f"/api/v1/management/{management_slug}")
assert managed.status_code == 200
assert managed.json()["hostname"] == body["hostname"]
deleted = client.delete(f"/api/v1/management/{management_slug}")
assert deleted.status_code == 204
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
assert response.status_code == 200
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
assert len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypatch) -> None:
def fail_publish(result, settings):
raise RuntimeError("dns down")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
assert response.status_code == 500
assert store.list_records() == []
assert store.list_update_events()[0]["status"] == "dns_failed"
def test_api_custom_hostname_requires_verified_domain(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
assert challenge.status_code == 201
challenge_body = challenge.json()
verified = client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge_body["claim_secret"]},
)
assert verified.status_code == 200
assert verified.json()["verified"] is True
hostname = client.post(
"/api/v1/hostnames/custom",
json={
"hostname": "home.example.net",
"claim_secret": challenge_body["claim_secret"],
"username": "router",
},
)
assert hostname.status_code == 201
assert hostname.json()["hostname"] == "home.example.net"
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert first.status_code == 201
assert second.status_code == 429
def test_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
created = client.post("/magic", data={"username": "router"})
assert created.status_code == 200
assert created.headers["cache-control"] == "no-store"
assert created.headers["pragma"] == "no-cache"
def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
max_request_body_bytes=1024,
)
)
client = TestClient(app)
response = client.post("/magic", data={"username": "x" * 2000})
assert response.status_code == 413
def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.post("/magic", data={"username": "alice"}, headers={"x-forwarded-for": "198.51.100.10"})
second = client.post("/magic", data={"username": "bob"}, headers={"x-forwarded-for": "198.51.100.11"})
assert first.status_code == 200
assert second.status_code == 429
def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 200
record = store.list_records()[0]
assert record["ipv4"] == "203.0.113.9"
assert record["ipv6"] == "2001:db8::8"
assert published[0].update_ipv4 is True
assert published[0].update_ipv6 is False
def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
verified = client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
)
overwrite = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
assert verified.status_code == 200
assert overwrite.status_code == 409
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
dns_zones={"ddns.example.net"},
)
)
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
)
hostname = client.post(
"/api/v1/hostnames/custom",
json={"hostname": "home.example.net", "claim_secret": challenge["claim_secret"]},
)
assert hostname.status_code == 403
assert hostname.json()["detail"] == "hostname is outside configured DNS publishing zones"
def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> None:
try:
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
require_dns_provider=True,
)
)
except RuntimeError as exc:
assert "no DNS backend" in str(exc)
else:
raise AssertionError("expected startup failure")
def test_launch_mode_requires_strong_admin_password(tmp_path: Path) -> None:
try:
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
require_dns_provider=True,
)
)
except RuntimeError as exc:
assert "DDNS_ADMIN_PASSWORD" in str(exc)
else:
raise AssertionError("expected startup failure")
def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
cleanup_challenge_hours=1,
cleanup_unused_account_hours=1,
cleanup_interval_seconds=1,
)
)
store = DdnsStore(database_path)
with TestClient(app):
abandoned_claim = store.create_domain_challenge("abandoned.example.net")
verified_claim = store.create_domain_challenge("verified.example.net")
abandoned_account = store.create_account("abandoned.ddns.example.net", "router")
active_account = store.create_account("active.ddns.example.net", "router")
store.upsert("active.ddns.example.net", "203.0.113.10", None)
old = "2000-01-01T00:00:00+00:00"
with store.connect() as conn:
conn.execute("UPDATE domain_challenges SET created_at = ? WHERE domain = ?", (old, abandoned_claim["domain"]))
conn.execute(
"UPDATE domain_challenges SET created_at = ?, verified_at = ? WHERE domain = ?",
(old, old, verified_claim["domain"]),
)
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"]))
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"]))
time.sleep(1.3)
assert store.get_domain_challenge("abandoned.example.net") is None
assert store.get_domain_challenge("verified.example.net") is not None
assert store.get_account_by_slug(abandoned_account["update_slug"]) is None
assert store.get_account_by_slug(active_account["update_slug"]) is not None
+261
View File
@@ -0,0 +1,261 @@
import asyncio
import logging
import time
from contextlib import contextmanager
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import router_dyndns.app as app_module
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
def test_rate_limit_reject_does_not_increment_bucket(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
assert store.allow_rate_limit("198.51.100.1:/magic", 1) is True
assert store.allow_rate_limit("198.51.100.1:/magic", 1) is False
store.flush_rate_limits()
window = int(time.time()) // 60
with store.connect() as conn:
count = conn.execute(
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
("198.51.100.1:/magic", window),
).fetchone()[0]
assert count == 1
def test_rate_limit_flush_persists_batched_counts(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
store.flush_rate_limits()
window = int(time.time()) // 60
with store.connect() as conn:
count = conn.execute(
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
("198.51.100.1:/api", window),
).fetchone()[0]
assert count == 2
def test_cleanup_deletes_old_rate_limit_minute_buckets(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
current_window = int(time.time()) // 60
with store.connect() as conn:
conn.execute(
"INSERT INTO rate_limits(key, window, count) VALUES (?, ?, ?)",
("old", current_window - 61, 1),
)
conn.execute(
"INSERT INTO rate_limits(key, window, count) VALUES (?, ?, ?)",
("recent", current_window - 30, 1),
)
store.cleanup(challenge_hours=1, unused_account_hours=1)
with store.connect() as conn:
keys = {row[0] for row in conn.execute("SELECT key FROM rate_limits")}
assert keys == {"recent"}
def test_scheduled_cleanup_task_is_durable_and_lease_protected(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
first = DdnsStore(database_path)
first.ensure_scheduled_task("cleanup", 3600)
second = DdnsStore(database_path)
task = second.list_scheduled_tasks()[0]
assert task["name"] == "cleanup"
assert task["interval_seconds"] == 3600
assert task["last_status"] == "pending"
assert first.acquire_due_task("cleanup", "worker-a", 60) is True
assert second.acquire_due_task("cleanup", "worker-b", 60) is False
first.complete_scheduled_task("cleanup", 3600, True)
completed = second.list_scheduled_tasks()[0]
assert completed["lease_owner"] is None
assert completed["lease_expires_at"] is None
assert completed["last_status"] == "ok"
assert completed["last_finished_at"]
assert completed["next_run_at"] > str(task["next_run_at"])
def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: Path) -> None:
database_path = tmp_path / "nested" / "ddns.sqlite3"
settings = DdnsSettings(database_path=database_path, admin_password="admin")
make_app(settings)
make_app(settings)
store = DdnsStore(database_path)
with store.connect() as conn:
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
assert {
"accounts",
"cleanup_runs",
"domain_challenges",
"rate_limits",
"records",
"scheduled_tasks",
"update_events",
}.issubset(tables)
assert len(store.list_scheduled_tasks()) == 1
assert store.list_scheduled_tasks()[0]["name"] == "cleanup"
def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
DdnsStore(database_path)
store = DdnsStore(database_path)
store.optimize()
store.checkpoint_wal()
with store.connect() as conn:
busy_timeout = conn.execute("PRAGMA busy_timeout").fetchone()[0]
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
synchronous = conn.execute("PRAGMA synchronous").fetchone()[0]
cache_size = conn.execute("PRAGMA cache_size").fetchone()[0]
temp_store = conn.execute("PRAGMA temp_store").fetchone()[0]
mmap_size = conn.execute("PRAGMA mmap_size").fetchone()[0]
user_version = conn.execute("PRAGMA user_version").fetchone()[0]
index_names = {
row[1]
for row in conn.execute(
"""
SELECT type, name
FROM sqlite_master
WHERE type = 'index'
"""
)
}
assert busy_timeout == 10_000
assert journal_mode == "wal"
assert synchronous == 1
assert cache_size == -20_000
assert temp_store == 2
assert mmap_size == 256 * 1024 * 1024
assert user_version == 1
assert {
"idx_accounts_created_at",
"idx_domain_challenges_claim_hash",
"idx_domain_challenges_unverified_created",
"idx_rate_limits_window",
"idx_update_events_hostname_created_at",
"idx_update_event_rollups_hostname_last_seen_at",
}.issubset(index_names)
def test_rate_limit_flush_requeues_counts_after_database_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
original_connect = store.connect
@contextmanager
def fail_connect():
raise RuntimeError("database temporarily unavailable")
yield
monkeypatch.setattr(store, "connect", fail_connect)
with pytest.raises(RuntimeError, match="database temporarily unavailable"):
store.flush_rate_limits()
monkeypatch.setattr(store, "connect", original_connect)
store.flush_rate_limits()
window = int(time.time()) // 60
with store.connect() as conn:
count = conn.execute(
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
("198.51.100.1:/api", window),
).fetchone()[0]
assert count == 1
def test_cleanup_prunes_old_raw_update_events(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
store.log_update_event("old.example.net", "203.0.113.1", None, "updated", "old", "198.51.100.1")
store.log_update_event("new.example.net", "203.0.113.2", None, "updated", "new", "198.51.100.2")
with store.connect() as conn:
conn.execute(
"UPDATE update_events SET created_at = ? WHERE hostname = ?",
("2000-01-01T00:00:00+00:00", "old.example.net"),
)
result = store.cleanup(challenge_hours=1, unused_account_hours=1, event_history_hours=24)
assert result["update_events"] == 1
events = store.list_update_events()
assert [event["hostname"] for event in events] == ["new.example.net"]
def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
cleanup_challenge_hours=1,
cleanup_unused_account_hours=1,
cleanup_interval_seconds=1,
)
)
store = DdnsStore(database_path)
with TestClient(app):
abandoned_claim = store.create_domain_challenge("abandoned.example.net")
verified_claim = store.create_domain_challenge("verified.example.net")
abandoned_account = store.create_account("abandoned.ddns.example.net", "router")
active_account = store.create_account("active.ddns.example.net", "router")
store.upsert("active.ddns.example.net", "203.0.113.10", None)
old = "2000-01-01T00:00:00+00:00"
with store.connect() as conn:
conn.execute("UPDATE domain_challenges SET created_at = ? WHERE domain = ?", (old, abandoned_claim["domain"]))
conn.execute(
"UPDATE domain_challenges SET created_at = ?, verified_at = ? WHERE domain = ?",
(old, old, verified_claim["domain"]),
)
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"]))
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"]))
time.sleep(1.3)
assert store.get_domain_challenge("abandoned.example.net") is None
assert store.get_domain_challenge("verified.example.net") is not None
assert store.get_account_by_slug(abandoned_account["update_slug"]) is None
assert store.get_account_by_slug(active_account["update_slug"]) is not None
scheduled_task = store.list_scheduled_tasks()[0]
assert scheduled_task["name"] == "cleanup"
assert scheduled_task["last_status"] == "ok"
assert scheduled_task["last_finished_at"]
def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog) -> None:
database_path = tmp_path / "ddns.sqlite3"
settings = DdnsSettings(database_path=database_path, admin_password="admin", cleanup_interval_seconds=60)
store = DdnsStore(database_path)
store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
def fail_cleanup(challenge_hours, unused_account_hours, event_history_hours):
raise RuntimeError("cleanup exploded")
caplog.set_level(logging.ERROR, logger="router_dyndns.app")
monkeypatch.setattr(store, "cleanup", fail_cleanup)
asyncio.run(app_module._run_cleanup_if_due(store, settings))
scheduled_task = store.list_scheduled_tasks()[0]
assert scheduled_task["last_status"] == "error"
assert "RuntimeError: cleanup exploded" in str(scheduled_task["last_error"])
assert "Scheduled cleanup failed" in caplog.text
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, make_app
ROOT = Path(__file__).parents[1]
def run_script(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
return subprocess.run(
args,
check=True,
cwd=ROOT,
env=merged_env,
text=True,
capture_output=True,
)
def test_shell_scripts_parse() -> None:
for script in ("install-linux.sh", "karldns-update.sh", "uninstall-linux.sh"):
run_script("sh", "-n", str(ROOT / "scripts" / script))
def test_updater_strips_fritzbox_placeholders_and_caches_ip(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="https://karldns.de",
hostname_suffix="karldns.de",
)
)
client = TestClient(app)
account = client.post("/api/v1/hostnames/magic", json={}).json()
update_url = account["update_url"]
script = tmp_path / "curl"
log_file = tmp_path / "curl.log"
cache_file = tmp_path / "last-ip"
lock_file = tmp_path / "updater.lock"
script.write_text(
f"""#!/bin/sh
printf '%s\\n' "$*" >> {log_file}
case "$*" in
*'/ip'*) printf '203.0.113.25\\n' ;;
*'<ipaddr>'*|*'<ip6addr>'*) printf 'bad placeholder leaked\\n'; exit 1 ;;
*'myip=203.0.113.25'*) printf 'good 203.0.113.25\\n' ;;
*) printf 'bad request\\n'; exit 1 ;;
esac
""",
encoding="utf-8",
)
script.chmod(0o700)
result = run_script(
"sh",
str(ROOT / "scripts" / "karldns-update.sh"),
env={
"KARLDNS_UPDATE_URL": update_url,
"KARLDNS_CURL": str(script),
"KARLDNS_IP_URL": "https://karldns.de/ip",
"KARLDNS_CACHE_FILE": str(cache_file),
"KARLDNS_LOCK_FILE": str(lock_file),
"KARLDNS_IPV6": "off",
},
)
assert "good 203.0.113.25" in result.stdout
assert cache_file.read_text(encoding="utf-8").strip() == "203.0.113.25|"
assert "<ipaddr>" not in log_file.read_text(encoding="utf-8")
+321
View File
@@ -0,0 +1,321 @@
import logging
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
from router_dyndns.ddns_dns import validate_dns_provider_settings
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_account("public-target.ddns.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "public-target.ddns.example.net",
"myip": "203.0.113.9",
},
)
assert response.status_code == 401
assert store.list_records() == []
def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "other.example.net",
"myip": "203.0.113.9",
"username": account["username"],
"password": account["password"],
},
)
assert response.status_code == 401
def test_shared_secret_without_allowed_hosts_cannot_update_arbitrary_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
app = make_app(DdnsSettings(database_path=database_path, shared_secret="secret", admin_password="admin"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "other.example.net", "myip": "203.0.113.9", "password": "secret"},
)
assert response.status_code == 401
assert response.text == "badauth"
assert store.list_records() == []
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.get("/m/not-a-real-token")
second = client.get("/m/another-token")
assert first.status_code == 404
assert second.status_code == 429
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
trusted_hosts={"ddns.example.net"},
)
)
client = TestClient(app)
response = client.get("/", headers={"host": "evil.example.net"})
assert response.status_code == 400
def test_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
created = client.post("/magic", data={"username": "router"})
assert created.status_code == 200
assert created.headers["cache-control"] == "no-store"
assert created.headers["pragma"] == "no-cache"
def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
max_request_body_bytes=1024,
)
)
client = TestClient(app)
response = client.post("/magic", data={"username": "x" * 2000})
assert response.status_code == 413
def test_invalid_utf8_form_body_is_rejected(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.post(
"/magic",
content=b"username=\xff",
headers={"content-type": "application/x-www-form-urlencoded"},
)
assert response.status_code == 400
assert response.json()["detail"] == "form body must be valid UTF-8"
def test_too_many_form_fields_are_rejected(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
body = "&".join(f"k{index}=v" for index in range(101))
response = client.post(
"/magic",
content=body.encode("ascii"),
headers={"content-type": "application/x-www-form-urlencoded"},
)
assert response.status_code == 400
assert response.json()["detail"] == "form must not contain more than 100 fields"
def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.post("/magic", data={"username": "alice"}, headers={"x-forwarded-for": "198.51.100.10"})
second = client.post("/magic", data={"username": "bob"}, headers={"x-forwarded-for": "198.51.100.11"})
assert first.status_code == 200
assert second.status_code == 429
def test_dns_delete_failure_is_logged_without_secret_management_slug(tmp_path: Path, monkeypatch, caplog) -> None:
def fail_delete(hostname, settings):
raise DnsProviderError("dns down")
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
monkeypatch.setattr(ddns_service, "delete_dns_records", fail_delete)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
with pytest.raises(DnsProviderError):
app.state.service.delete_account("home.example.net", "unit-test")
assert store.get_account_by_slug(account["update_slug"]) is not None
assert store.list_update_events()[0]["status"] == "dns_delete_failed"
assert "DNS delete failed for hostname=home.example.net source=unit-test" in caplog.text
assert account["management_slug"] not in caplog.text
assert "dns down" not in caplog.text
def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> None:
try:
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
require_dns_provider=True,
)
)
except RuntimeError as exc:
assert "no DNS backend" in str(exc)
else:
raise AssertionError("expected startup failure")
def test_invalid_dns_provider_is_rejected() -> None:
settings = DdnsSettings(dns_provider="route53")
with pytest.raises(RuntimeError, match="Unsupported DDNS_DNS_PROVIDER"):
validate_dns_provider_settings(settings)
def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_CLOUDFLARE_API_TOKEN"):
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="a" * 16,
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="cloudflare",
require_dns_provider=True,
)
)
def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE"):
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="a" * 16,
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="rfc2136",
rfc2136_server="192.0.2.53",
require_dns_provider=True,
)
)
def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
import dns.query
import dns.rcode
from router_dyndns.dns.rfc2136 import delete_rfc2136_records
class RefusedResponse:
def rcode(self) -> int:
return dns.rcode.REFUSED
monkeypatch.setattr(dns.query, "tcp", lambda *args, **kwargs: RefusedResponse())
with pytest.raises(DnsProviderError, match="RFC2136 delete failed"):
delete_rfc2136_records(
"home.example.net",
DdnsSettings(rfc2136_server="192.0.2.53", rfc2136_zone="example.net"),
)
def test_invalid_integer_env_value_fails_startup(monkeypatch) -> None:
monkeypatch.setenv("DDNS_RATE_LIMIT_PER_MINUTE", "not-an-int")
with pytest.raises(ValueError, match="DDNS_RATE_LIMIT_PER_MINUTE must be an integer"):
DdnsSettings.from_env()
def test_invalid_admin_path_fails_startup() -> None:
with pytest.raises(ValueError, match="admin_path"):
DdnsSettings(admin_path="admin")
with pytest.raises(ValueError, match="admin_path"):
DdnsSettings(admin_path="/")
with pytest.raises(ValueError, match="admin_path"):
DdnsSettings(admin_path="/admin?")
with pytest.raises(ValueError, match="public route"):
DdnsSettings(admin_path="/api")
def test_launch_mode_requires_strong_admin_password(tmp_path: Path) -> None:
try:
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
require_dns_provider=True,
)
)
except RuntimeError as exc:
assert "DDNS_ADMIN_PASSWORD" in str(exc)
else:
raise AssertionError("expected startup failure")
+683
View File
@@ -0,0 +1,683 @@
import re
from pathlib import Path
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
def _csrf(html: str) -> str:
match = re.search(r'name="csrf" value="([^"]+)"', html)
assert match
return match.group(1)
def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "Manual updates" in response.text
assert "curl -sS" in response.text
assert "203.0.113.10" in response.text
assert "2001:db8::10" in response.text
assert "Public:" in response.text
assert "Secret:" in response.text
assert ".ddns.example.net" in response.text
assert "/u/" in response.text
assert "/nic/update?" in response.text
assert "/m/" in response.text
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/magic")
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Private status page:" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "FRITZ!Box replaces" in response.text
assert "Copy curl examples" in response.text
assert response.text.count("Secret") >= 3
assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text
def test_magic_get_redirects_to_home_form(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/magic", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/#create"
def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
management = client.get(f"/m/{account['management_slug']}")
assert management.status_code == 200
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Copy complete FRITZ!Box fields" in management.text
assert "Copy CNAME record" in management.text
assert "Manual updates" in management.text
assert "curl -sS" in management.text
assert "Public:" in management.text
assert "Secret:" in management.text
assert "Router updates" in management.text
deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)})
assert deleted.status_code == 200
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 404
def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
dashboard = client.get(f"/m/{account['management_slug']}")
set_password = client.post(
f"/m/{account['management_slug']}/password",
data={
"csrf": _csrf(dashboard.text),
"dashboard_password": "correct horse",
"dashboard_password_confirm": "correct horse",
},
follow_redirects=False,
)
assert set_password.status_code == 303
assert "httponly" in set_password.headers["set-cookie"].lower()
assert "samesite=lax" in set_password.headers["set-cookie"].lower()
assert "secure" not in set_password.headers["set-cookie"].lower()
stored = app.state.store.get_account_by_management_slug(account["management_slug"])
assert stored["dashboard_password_hash"].startswith("pbkdf2_sha256$")
assert stored["dashboard_password_hash"] != "correct horse"
locked_client = TestClient(app)
locked = locked_client.get(f"/m/{account['management_slug']}")
assert locked.status_code == 200
assert "Dashboard locked" in locked.text
assert "Update-URL:" not in locked.text
assert "correct horse" not in locked.text
update = locked_client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 200
assert update.text == "good 203.0.113.9"
nic_update = locked_client.get(
"/nic/update",
params={
"hostname": account["hostname"],
"myip": "203.0.113.10",
"username": account["username"],
"password": account["password"],
},
)
assert nic_update.status_code == 200
assert nic_update.text == "good 203.0.113.10"
bad_unlock = locked_client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
)
assert bad_unlock.status_code == 401
assert "Incorrect dashboard password." in bad_unlock.text
assert "Update-URL:" not in bad_unlock.text
good_unlock = locked_client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"},
follow_redirects=False,
)
assert good_unlock.status_code == 303
assert "max-age=86400" in good_unlock.headers["set-cookie"].lower()
unlocked = locked_client.get(f"/m/{account['management_slug']}")
assert "Update-URL:" in unlocked.text
assert "Password protection" in unlocked.text
def test_management_dashboard_password_cookie_is_secure_on_https_public_url(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="https://ddns.example.net",
)
)
client = TestClient(app)
dashboard = client.get(f"/m/{account['management_slug']}")
response = client.post(
f"/m/{account['management_slug']}/password",
data={
"csrf": _csrf(dashboard.text),
"dashboard_password": "correct horse",
"dashboard_password_confirm": "correct horse",
},
follow_redirects=False,
)
assert response.status_code == 303
assert "secure" in response.headers["set-cookie"].lower()
def test_management_dashboard_password_can_be_removed(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
store.set_dashboard_password("home.ddns.example.net", "correct horse")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
unlock = client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(client.get(f"/m/{account['management_slug']}").text), "dashboard_password": "correct horse"},
)
assert unlock.status_code == 200
remove = client.post(
f"/m/{account['management_slug']}/password/remove",
data={"csrf": _csrf(unlock.text), "current_dashboard_password": "correct horse"},
)
assert remove.status_code == 200
assert "Not enabled" in remove.text
fresh_client = TestClient(app)
unlocked = fresh_client.get(f"/m/{account['management_slug']}")
assert unlocked.status_code == 200
assert "Update-URL:" in unlocked.text
def test_dashboard_stay_unlocked_cookie_uses_longer_lifetime(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
store.set_dashboard_password("home.ddns.example.net", "correct horse")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
dashboard_unlock_seconds=3600,
dashboard_stay_unlocked_seconds=30 * 24 * 60 * 60,
)
)
client = TestClient(app)
locked = client.get(f"/m/{account['management_slug']}")
response = client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse", "stay_unlocked": "1"},
follow_redirects=False,
)
assert response.status_code == 303
assert "max-age=2592000" in response.headers["set-cookie"].lower()
def test_unlocked_dashboard_posts_require_csrf(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
assert response.status_code == 403
assert store.get_account_by_hostname("home.ddns.example.net")["update_slug"] == account["update_slug"]
def test_existing_dashboard_password_requires_current_password_to_change_or_remove(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
store.set_dashboard_password("home.ddns.example.net", "correct horse")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
locked = client.get(f"/m/{account['management_slug']}")
unlocked = client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"},
)
change = client.post(
f"/m/{account['management_slug']}/password",
data={
"csrf": _csrf(unlocked.text),
"current_dashboard_password": "wrong",
"dashboard_password": "new password",
"dashboard_password_confirm": "new password",
},
)
remove = client.post(
f"/m/{account['management_slug']}/password/remove",
data={"csrf": _csrf(unlocked.text), "current_dashboard_password": "wrong"},
)
assert change.status_code == 400
assert remove.status_code == 400
assert "Current dashboard password is incorrect." in change.text
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
dashboard = client.get(f"/m/{account['management_slug']}")
assert dashboard.status_code == 200
assert "Rotate Update-URL" in dashboard.text
assert "Rotate router credentials" in dashboard.text
assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text
rotated_update = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
assert rotated_update.status_code == 200
assert "Update-URL rotated." in rotated_update.text
assert "data-secret-input" in rotated_update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
rotated_password = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(rotated_update.text), "action": "password"})
assert rotated_password.status_code == 200
assert "Router credentials rotated" in rotated_password.text
assert "One-box update URL:" in rotated_password.text
assert "Kennwort (optional):" in rotated_password.text
assert "data-secret-toggle" in rotated_password.text
assert client.get(
"/nic/update",
params={
"hostname": "home.ddns.example.net",
"username": "router",
"password": account["password"],
"myip": "203.0.113.9",
},
).status_code == 401
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)}
assert "dashboard_update_url_rotated" in statuses
assert "dashboard_router_credentials_rotated" in statuses
def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
first = store.create_account("first.ddns.example.net", "first")
second = store.create_account("second.ddns.example.net", "second")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
dashboard = client.get(f"/m/{first['management_slug']}")
rotated = client.post(f"/m/{first['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
assert rotated.status_code == 200
assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"]
assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"]
assert client.get(f"/u/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
assert client.get(f"/u/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("locked.ddns.example.net", "router")
store.set_dashboard_password("locked.ddns.example.net", "correct horse")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
rotate = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
delete = client.post(f"/m/{account['management_slug']}/delete")
claim = client.post(f"/m/{account['management_slug']}/domains/request", data={"domain": "example.net"})
assert rotate.status_code == 401
assert delete.status_code == 401
assert claim.status_code == 401
assert store.get_account_by_hostname("locked.ddns.example.net")["update_slug"] == account["update_slug"]
assert store.get_account_by_slug(account["update_slug"]) is not None
assert store.get_domain_challenge("example.net") is None
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
store.log_update_event("home.ddns.example.net", "203.0.113.9", None, "updated", "ok", "198.51.100.1")
store.log_update_event("home.ddns.example.net", "203.0.113.10", None, "updated", "ok", "198.51.100.1")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/m/{account['management_slug']}")
assert response.status_code == 200
assert "Router updates" in response.text
assert "203.0.113.9" in response.text
assert "203.0.113.10" in response.text
def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("base.ddns.example.net", "router")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
dashboard = client.get(f"/m/{account['management_slug']}")
challenge = client.post(f"/m/{account['management_slug']}/domains/request", data={"csrf": _csrf(dashboard.text), "domain": "example.net"})
assert challenge.status_code == 200
assert "Domain claim created" in challenge.text
assert "TXT name" in challenge.text
assert "TXT value" in challenge.text
assert "Claim secret" in challenge.text
assert "Private claim link" in challenge.text
assert "This claim belongs to this dashboard" in challenge.text
assert "data-secret-toggle" in challenge.text
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
verified = client.post(
f"/m/{account['management_slug']}/domains/verify",
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
)
assert verified.status_code == 200
assert "Domain verified" in verified.text
assert "Create custom hostname" in verified.text
created = client.post(
f"/m/{account['management_slug']}/domains/hostnames",
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "Custom hostname created" in created.text
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
assert "Private status page:" in created.text
def test_dashboard_domain_claims_are_bound_to_creating_dashboard(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
owner = store.create_account("owner.ddns.example.net", "owner")
other = store.create_account("other.ddns.example.net", "other")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
owner_dashboard = client.get(f"/m/{owner['management_slug']}")
challenge = client.post(f"/m/{owner['management_slug']}/domains/request", data={"csrf": _csrf(owner_dashboard.text), "domain": "example.net"})
assert challenge.status_code == 200
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
verify_other = client.post(
f"/m/{other['management_slug']}/domains/verify",
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "domain": "example.net", "claim_secret": claim_secret},
)
create_other = client.post(
f"/m/{other['management_slug']}/domains/hostnames",
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "hostname": "home.example.net", "claim_secret": claim_secret},
)
verify_owner = client.post(
f"/m/{owner['management_slug']}/domains/verify",
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
)
assert verify_other.status_code == 404
assert create_other.status_code == 403
assert verify_owner.status_code == 200
assert "Domain verified" in verify_owner.text
assert store.get_account_by_hostname("home.example.net") is None
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
assert response.status_code == 403
def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
store = DdnsStore(tmp_path / "ddns.sqlite3")
challenge_data = store.create_domain_challenge("example.net")
verify = client.post(
"/verify-domain",
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
)
assert verify.status_code == 200
response = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
)
assert response.status_code == 200
assert "home.example.net" in response.text
assert "Update-URL:" in response.text
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
challenge = client.post("/request-domain", data={"domain": "example.net"})
assert challenge.status_code == 200
assert "Private claim link" in challenge.text
assert "/d/" in challenge.text
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
claim_page = client.get(f"/d/{claim_secret}")
assert claim_page.status_code == 200
assert "TXT value" in claim_page.text
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
assert verify.status_code == 200
assert "Create credentials" in verify.text
created = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/admin"' not in response.text
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "generates the hostname, secret update URL, and optional router compatibility credentials automatically" in response.text
assert "Linux cron updater" in response.text
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
assert "--delete-config" in response.text
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="karldns.de",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "https://karldns.de/u/&lt;secret&gt;" in response.text
assert "http://127.0.0.1" not in response.text
assert "http://localhost" not in response.text
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert '<script src="/app.js" defer></script>' in response.text
assert '<link href="/app.css" rel="stylesheet">' in response.text
assert "<style>" not in response.text
assert "script-src 'self'" in response.headers["content-security-policy"]
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert "style-src 'self' https://cdn.jsdelivr.net" in response.headers["content-security-policy"]
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert client.get("/app.js").status_code == 200
app_css = client.get("/app.css")
assert app_css.status_code == 200
assert "text/css" in app_css.headers["content-type"]
assert app_css.headers["cache-control"] == "public, max-age=3600"
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "Made by Karl" in response.text
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text
assert 'href="https://karlcom.de"' in response.text
assert 'href="/privacy"' in response.text
privacy = client.get("/privacy")
assert privacy.status_code == 200
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
assert "hashed router passwords" in privacy.text
assert "Made by Karl" in privacy.text
def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/favicon.ico"' in response.text
assert 'href="/favicon-32x32.png"' in response.text
assert 'href="/favicon-16x16.png"' in response.text
assert 'href="/apple-touch-icon.png"' in response.text
assert 'href="/site.webmanifest"' in response.text
for path in [
"/favicon.ico",
"/favicon-32x32.png",
"/favicon-16x16.png",
"/apple-touch-icon.png",
"/logo-192.png",
"/logo-512.png",
]:
asset = client.get(path)
assert asset.status_code == 200
assert asset.content
manifest = client.get("/site.webmanifest")
assert manifest.status_code == 200
assert manifest.json()["icons"] == [
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"},
]
+321
View File
@@ -0,0 +1,321 @@
import logging
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
shared_secret="secret",
admin_password="admin",
allowed_hosts={"home.example.net"},
)
)
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "home.example.net",
"myip": "203.0.113.8",
"myipv6": "2001:db8::8",
"password": "secret",
},
)
assert response.status_code == 200
assert response.text == "good 203.0.113.8"
records = client.get("/records", auth=("admin", "admin")).json()
assert records["total"] == 1
assert records["items"][0]["hostname"] == "home.example.net"
assert records["items"][0]["ipv4"] == "203.0.113.8"
assert records["items"][0]["ipv6"] == "2001:db8::8"
def test_update_rejects_wrong_secret(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "home.example.net", "myip": "203.0.113.8", "password": "wrong"},
)
assert response.status_code == 401
def test_update_rejects_unlisted_host(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
shared_secret="secret",
allowed_hosts={"home.example.net"},
)
)
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "other.example.net", "myip": "203.0.113.8", "password": "secret"},
)
assert response.status_code == 403
def test_generated_account_can_update_own_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "home.example.net",
"myip": "203.0.113.9",
"username": account["username"],
"password": account["password"],
},
)
assert response.status_code == 200
assert response.text == "good 203.0.113.9"
slug_response = client.get(
f"/u/{account['update_slug']}",
params={"myip": "203.0.113.10"},
)
assert slug_response.status_code == 200
assert slug_response.text == "good 203.0.113.10"
def test_one_box_update_url_can_authenticate_with_query_credentials(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
settings = DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
app = make_app(settings)
client = TestClient(app)
one_box_url = ddns_service.DdnsService(settings, store).credentialed_update_url(account)
parsed = urlparse(one_box_url)
params = parse_qs(parsed.query)
params["myip"] = ["203.0.113.11"]
params["myipv6"] = [""]
response = client.get(parsed.path, params={key: value[0] for key, value in params.items()})
assert response.status_code == 200
assert response.text == "good 203.0.113.11"
assert response.headers["cache-control"] == "no-store"
def test_dns_publish_failure_does_not_update_record(tmp_path: Path, monkeypatch, caplog) -> None:
def fail_publish(result, settings):
raise DnsProviderError("dns down")
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 500
assert store.list_records() == []
events = store.list_update_events()
assert events[0]["status"] == "dns_failed"
assert "DNS publish failed for hostname=home.example.net" in caplog.text
assert account["update_slug"] not in caplog.text
assert "dns down" not in caplog.text
def test_programming_error_during_publish_is_not_mapped_to_dns_failure(tmp_path: Path, monkeypatch) -> None:
def fail_publish(result, settings):
raise AssertionError("publish bug")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
with pytest.raises(AssertionError, match="publish bug"):
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert store.list_records() == []
assert store.list_update_events() == []
def test_dns_publish_success_updates_record(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 200
assert response.text == "good 203.0.113.9"
assert len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.9"
def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
store.upsert("home.example.net", "203.0.113.9", None)
before = store.list_records()[0]["updated_at"]
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
second_response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 200
assert response.text == "nochg 203.0.113.9"
assert second_response.status_code == 200
assert store.list_records()[0]["updated_at"] == before
events = store.list_update_events()
assert events[0]["status"] == "unchanged"
assert events[0]["count"] == 2
assert published == []
def test_shared_secret_still_updates_explicitly_allowed_hostname(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
shared_secret="secret",
admin_password="admin",
allowed_hosts={"home.example.net"},
)
)
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "home.example.net", "myip": "203.0.113.9", "password": "secret"},
)
assert response.status_code == 200
assert response.text == "good 203.0.113.9"
def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 200
record = store.list_records()[0]
assert record["ipv4"] == "203.0.113.9"
assert record["ipv6"] == "2001:db8::8"
assert published[0].update_ipv4 is True
assert published[0].update_ipv6 is False
def test_blank_ip_params_fall_back_to_source_ip(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app, client=("198.51.100.44", 50000))
response = client.get(
f"/u/{account['update_slug']}",
params={"myip": "", "myipv6": ""},
)
assert response.status_code == 200
assert response.text == "good 198.51.100.44"
assert store.list_records()[0]["ipv4"] == "198.51.100.44"
def test_invalid_legacy_ip_is_badip_and_does_not_change_record(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "not-an-ip"})
assert response.status_code == 400
assert response.text == "badip"
assert published == []
record = store.list_records()[0]
assert record["ipv4"] == "203.0.113.8"
assert record["ipv6"] == "2001:db8::8"
def test_wrong_family_legacy_ip_is_badip_and_preserves_records(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "2001:db8::9"})
assert response.status_code == 400
assert response.text == "badip"
record = store.list_records()[0]
assert record["ipv4"] == "203.0.113.8"
assert record["ipv6"] == "2001:db8::8"
def test_nic_update_invalid_ip_returns_badip(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
app = make_app(
DdnsSettings(
database_path=database_path,
shared_secret="secret",
admin_password="admin",
allowed_hosts={"home.example.net"},
)
)
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "home.example.net", "myipv6": "203.0.113.9", "password": "secret"},
)
assert response.status_code == 400
assert response.text == "badip"
assert store.list_records() == []
Generated
+718
View File
@@ -0,0 +1,718 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "click"
version = "8.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" },
{ url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" },
{ url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" },
{ url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" },
{ url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" },
{ url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" },
{ url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" },
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "karldns"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "dnspython" },
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "dnspython", specifier = ">=2.6" },
{ name = "fastapi", specifier = ">=0.111" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
]
provides-extras = ["dev"]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.47.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" },
{ url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" },
{ url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" },
{ url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" },
{ url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" },
{ url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" },
{ url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" },
{ url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" },
{ url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" },
{ url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" },
{ url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" },
{ url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
{ url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" },
{ url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" },
{ url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]