From ca92dc211aeca22cc7e6781d8444fd1e89f8f034 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 23:55:27 +0200 Subject: [PATCH] Build KarlDNS self-hosted DynDNS service --- .env.example | 35 +- .gitignore | 2 + Caddyfile.example | 2 +- Dockerfile | 10 +- README.md | 147 +- docker-compose.yml | 9 +- pyproject.toml | 7 +- router_dyndns/app.py | 160 ++ router_dyndns/cli.py | 8 +- router_dyndns/config.py | 58 + router_dyndns/ddns.py | 1556 +---------------- router_dyndns/ddns_api.py | 268 +-- router_dyndns/ddns_dns.py | 176 +- router_dyndns/ddns_models.py | 93 +- router_dyndns/ddns_security.py | 69 +- router_dyndns/ddns_service.py | 131 +- router_dyndns/ddns_store.py | 511 +----- router_dyndns/dependencies.py | 81 + router_dyndns/dns/__init__.py | 25 + router_dyndns/dns/base.py | 78 + router_dyndns/dns/cloudflare.py | 79 + router_dyndns/dns/rfc2136.py | 66 + router_dyndns/event_statuses.py | 22 + router_dyndns/routers/__init__.py | 0 router_dyndns/routers/admin.py | 235 +++ router_dyndns/routers/api.py | 179 ++ router_dyndns/routers/assets.py | 253 +++ router_dyndns/routers/management.py | 153 ++ router_dyndns/routers/management_domains.py | 153 ++ router_dyndns/routers/management_passwords.py | 146 ++ router_dyndns/routers/management_shared.py | 123 ++ router_dyndns/routers/public.py | 89 + router_dyndns/routers/updates.py | 60 + router_dyndns/schemas.py | 97 + router_dyndns/store/__init__.py | 80 + router_dyndns/store/accounts.py | 180 ++ router_dyndns/store/domains.py | 139 ++ router_dyndns/store/rate_limit.py | 166 ++ router_dyndns/store/records.py | 221 +++ router_dyndns/store/scheduler.py | 99 ++ router_dyndns/store/schema.py | 193 ++ router_dyndns/store/types.py | 57 + router_dyndns/web/__init__.py | 0 router_dyndns/web/forms.py | 31 + router_dyndns/web/html_utils.py | 23 + router_dyndns/web/security_headers.py | 120 ++ router_dyndns/web/styles.py | 780 +++++++++ router_dyndns/web/templates.py | 207 +++ router_dyndns/web/templates_admin.py | 233 +++ router_dyndns/web/templates_admin_tables.py | 103 ++ router_dyndns/web/templates_management.py | 377 ++++ router_dyndns/web/templates_public.py | 134 ++ .../web/templates_public_sections.py | 191 ++ scripts/install-linux.sh | 247 +++ scripts/karldns-update.sh | 93 + scripts/uninstall-linux.sh | 84 + tests/test_admin.py | 221 +++ tests/test_api.py | 351 ++++ tests/test_ddns.py | 976 ----------- tests/test_scheduler.py | 261 +++ tests/test_scripts.py | 79 + tests/test_security.py | 321 ++++ tests/test_ui.py | 683 ++++++++ tests/test_updates.py | 321 ++++ uv.lock | 718 ++++++++ 65 files changed, 9200 insertions(+), 3570 deletions(-) create mode 100644 router_dyndns/app.py create mode 100644 router_dyndns/config.py create mode 100644 router_dyndns/dependencies.py create mode 100644 router_dyndns/dns/__init__.py create mode 100644 router_dyndns/dns/base.py create mode 100644 router_dyndns/dns/cloudflare.py create mode 100644 router_dyndns/dns/rfc2136.py create mode 100644 router_dyndns/event_statuses.py create mode 100644 router_dyndns/routers/__init__.py create mode 100644 router_dyndns/routers/admin.py create mode 100644 router_dyndns/routers/api.py create mode 100644 router_dyndns/routers/assets.py create mode 100644 router_dyndns/routers/management.py create mode 100644 router_dyndns/routers/management_domains.py create mode 100644 router_dyndns/routers/management_passwords.py create mode 100644 router_dyndns/routers/management_shared.py create mode 100644 router_dyndns/routers/public.py create mode 100644 router_dyndns/routers/updates.py create mode 100644 router_dyndns/schemas.py create mode 100644 router_dyndns/store/__init__.py create mode 100644 router_dyndns/store/accounts.py create mode 100644 router_dyndns/store/domains.py create mode 100644 router_dyndns/store/rate_limit.py create mode 100644 router_dyndns/store/records.py create mode 100644 router_dyndns/store/scheduler.py create mode 100644 router_dyndns/store/schema.py create mode 100644 router_dyndns/store/types.py create mode 100644 router_dyndns/web/__init__.py create mode 100644 router_dyndns/web/forms.py create mode 100644 router_dyndns/web/html_utils.py create mode 100644 router_dyndns/web/security_headers.py create mode 100644 router_dyndns/web/styles.py create mode 100644 router_dyndns/web/templates.py create mode 100644 router_dyndns/web/templates_admin.py create mode 100644 router_dyndns/web/templates_admin_tables.py create mode 100644 router_dyndns/web/templates_management.py create mode 100644 router_dyndns/web/templates_public.py create mode 100644 router_dyndns/web/templates_public_sections.py create mode 100755 scripts/install-linux.sh create mode 100755 scripts/karldns-update.sh create mode 100755 scripts/uninstall-linux.sh create mode 100644 tests/test_admin.py create mode 100644 tests/test_api.py delete mode 100644 tests/test_ddns.py create mode 100644 tests/test_scheduler.py create mode 100644 tests/test_scripts.py create mode 100644 tests/test_security.py create mode 100644 tests/test_ui.py create mode 100644 tests/test_updates.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example index f801f80..63c593f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 1f2ad57..dbe15f0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ dist/ build/ *.egg-info/ tmp/ +backend_audit_report.md +security_best_practices_report.md diff --git a/Caddyfile.example b/Caddyfile.example index 2607729..0bd823c 100644 --- a/Caddyfile.example +++ b/Caddyfile.example @@ -1,4 +1,4 @@ karldns.de { encode zstd gzip - reverse_proxy ddns:8080 + reverse_proxy karldns:8080 } diff --git a/Dockerfile b/Dockerfile index 2e92219..d0a7a1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 2aa0003..46c663a 100644 --- a/README.md +++ b/README.md @@ -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=&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/' \ + --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. diff --git a/docker-compose.yml b/docker-compose.yml index 1a2cb8a..61c1cdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 66d108f..a2e4a1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/router_dyndns/app.py b/router_dyndns/app.py new file mode 100644 index 0000000..8adddc0 --- /dev/null +++ b/router_dyndns/app.py @@ -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) diff --git a/router_dyndns/cli.py b/router_dyndns/cli.py index 26056f8..8f6e12c 100644 --- a/router_dyndns/cli.py +++ b/router_dyndns/cli.py @@ -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() - diff --git a/router_dyndns/config.py b/router_dyndns/config.py new file mode 100644 index 0000000..1f5a57f --- /dev/null +++ b/router_dyndns/config.py @@ -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 diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index f79c373..e518adb 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -1,1565 +1,17 @@ from __future__ import annotations -import asyncio -import contextlib -import html -import secrets -import sqlite3 -import urllib.parse -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from pathlib import Path -from typing import Annotated - -from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response -from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse -from starlette.concurrency import run_in_threadpool -from starlette.middleware.trustedhost import TrustedHostMiddleware -from starlette.types import ASGIApp, Message, Receive, Scope, Send - -from .ddns_api import create_api_router -from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns +from .app import make_app +from .ddns_dns import DnsConfigurationError, DnsProviderError, delete_dns_records, publish_dns from .ddns_models import DdnsSettings, UpdateResult -from .ddns_security import ( - admin_csrf_token, - client_ip, - credentials_from_basic_auth, - is_rate_limited_path, - rate_limit_bucket, - require_admin_csrf, -) -from .ddns_service import DdnsService, normalize_domain, normalize_hostname from .ddns_store import DdnsStore __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 management for generated hostnames.", - }, -] - -ASSET_DIR = Path(__file__).with_name("assets") - -APP_JS = """ -(() => { - const applyTheme = () => { - const theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - document.documentElement.setAttribute("data-bs-theme", theme); - document.documentElement.style.colorScheme = theme; - }; - applyTheme(); - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme); -})(); - -document.addEventListener("click", async (event) => { - 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() - - -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 make_app(settings: DdnsSettings | None = None) -> FastAPI: - settings = settings or DdnsSettings.from_env() - if not settings.admin_password and settings.shared_secret: - settings = settings.model_copy(update={"admin_password": settings.shared_secret}) - if settings.require_dns_provider and not dns_backend_configured(settings): - raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured") - settings.validate_launch_ready() - - store = DdnsStore(settings.database_path) - store.cleanup(settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours) - service = DdnsService(settings, store) - - @asynccontextmanager - async def lifespan(_: FastAPI) -> AsyncIterator[None]: - cleanup_task = asyncio.create_task(_cleanup_loop(store, settings)) - try: - yield - finally: - cleanup_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cleanup_task - - 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.include_router(create_api_router(settings, store, service)) - - @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): - limit = settings.admin_rate_limit_per_minute if _is_admin_path(request.url.path) 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 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( - authorization: Annotated[str | None, Header()] = None, - ) -> None: - 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( - 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 Exception: - return PlainTextResponse("911 dns publish failed", status_code=500) - - @app.get("/", response_class=HTMLResponse) - def index() -> str: - return _render_public_home(service, settings, None, None) - - @app.get("/magic", include_in_schema=False) - def magic_form() -> RedirectResponse: - return RedirectResponse("/#create", status_code=303) - - @app.post("/magic", response_class=HTMLResponse) - async def magic_account(request: Request) -> str: - await _request_form(request, settings) - account = await run_in_threadpool(service.create_managed_account, None) - return _render_public_home(service, settings, account, None) - - @app.get("/m/{management_slug}", response_class=HTMLResponse) - def manage_magic(management_slug: str) -> str: - account = store.get_account_by_management_slug(management_slug) - if not account or account["disabled"]: - raise HTTPException(status_code=404, detail="not found") - return _render_management_page(service, settings, management_slug, account) - - @app.get("/d/{claim_secret}", response_class=HTMLResponse) - def manage_domain_claim(claim_secret: str) -> str: - challenge = store.get_domain_challenge_by_secret(claim_secret) - if not challenge: - raise HTTPException(status_code=404, detail="not found") - return _render_public_home(service, settings, None, None, challenge) - - @app.post("/m/{management_slug}/delete") - def delete_magic(management_slug: str) -> Response: - account = 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: - service.delete_account(hostname, "management-link") - except Exception: - return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500) - return HTMLResponse( - _page( - "DynDNS deleted", - """ -
-
-
-

Deleted

-

Hostname deleted.

-

The update URL no longer works.

- Home -
-
-
- """, - ) - ) - - @app.post("/request-domain", response_class=HTMLResponse) - async def request_domain(request: Request) -> 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) - - @app.post("/verify-domain", response_class=HTMLResponse) - async def verify_domain(request: Request) -> 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) - - @app.post("/accounts", response_class=HTMLResponse) - async def self_service_account(request: Request) -> 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) - - @app.get("/admin", response_class=HTMLResponse, include_in_schema=False) - def admin(_: None = Depends(authenticate_admin)) -> str: - return _render_admin_page( - service, - settings, - store.list_accounts(), - store.list_domain_challenges(), - store.list_cleanup_runs(), - store.list_update_events(25), - None, - ) - - @app.post("/admin/accounts/delete", include_in_schema=False) - async def admin_delete_account(request: Request, _: None = Depends(authenticate_admin)) -> Response: - form = await _request_form(request, settings) - require_admin_csrf(settings, _first_form_value(form, "csrf")) - hostname = _first_form_value(form, "hostname") - if hostname: - try: - service.delete_account(hostname, "admin") - except Exception: - return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500) - return RedirectResponse("/admin", status_code=303) - - @app.post("/admin/accounts/rotate", include_in_schema=False) - async def admin_rotate_account(request: Request, _: 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 = store.get_account_by_hostname(hostname) - if not account: - raise HTTPException(status_code=404, detail="hostname not found") - - notice = None - if action == "update": - rotated = store.rotate_update_slug(hostname) - notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None - elif action == "management": - rotated = store.rotate_management_slug(hostname) - 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 = store.rotate_password(hostname) - 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") - return HTMLResponse( - _render_admin_page( - service, - settings, - store.list_accounts(), - store.list_domain_challenges(), - store.list_cleanup_runs(), - store.list_update_events(25), - notice, - ) - ) - - @app.post("/admin/cleanup", include_in_schema=False) - async def admin_run_cleanup(request: Request, _: 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) - notice = _admin_secret_panel( - "Cleanup completed", - [ - _copy_row("Domain claims removed", str(result["domain_challenges"])), - _copy_row("Unused hostnames removed", str(result["unused_accounts"])), - ], - ) - return HTMLResponse( - _render_admin_page( - service, - settings, - store.list_accounts(), - store.list_domain_challenges(), - store.list_cleanup_runs(), - store.list_update_events(25), - notice, - ) - ) - - @app.get("/records", include_in_schema=False) - def records(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]: - return store.list_records() - - @app.get("/events", include_in_schema=False) - def events(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]: - return store.list_update_events() - - @app.get("/healthz", response_class=PlainTextResponse) - def healthz() -> str: - return "ok" - - @app.get("/privacy", response_class=HTMLResponse, include_in_schema=False) - def privacy() -> str: - return _render_privacy_page() - - @app.get("/favicon.ico", include_in_schema=False) - def favicon() -> FileResponse: - return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon") - - @app.get("/favicon-16x16.png", include_in_schema=False) - def favicon_16_png() -> FileResponse: - return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png") - - @app.get("/favicon-32x32.png", include_in_schema=False) - def favicon_32_png() -> FileResponse: - return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png") - - @app.get("/apple-touch-icon.png", include_in_schema=False) - def apple_touch_icon_png() -> FileResponse: - return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png") - - @app.get("/logo.png", include_in_schema=False) - def logo_png() -> FileResponse: - return FileResponse(ASSET_DIR / "logo.png", media_type="image/png") - - @app.get("/logo-192.png", include_in_schema=False) - def logo_192_png() -> FileResponse: - return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png") - - @app.get("/logo-512.png", include_in_schema=False) - def logo_512_png() -> FileResponse: - return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png") - - @app.get("/site.webmanifest", include_in_schema=False) - 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", - ) - - @app.get("/app.js", include_in_schema=False) - def app_js() -> Response: - return Response(APP_JS, media_type="application/javascript") - - @app.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None) - def slug_update( - update_slug: str, - request: Request, - 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(request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr) - - @app.get("/nic/update", response_class=PlainTextResponse, response_model=None) - def update( - request: Request, - 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 - global_secret_ok = settings.shared_secret and supplied_token and secrets.compare_digest(supplied_token, settings.shared_secret) - if not global_secret_ok and not store.verify_account(host, supplied_user, supplied_token): - return PlainTextResponse("badauth", status_code=401) - return apply_update(request, host, myip, ipaddr, myipv6, ip6addr) - - return app - - -async def _cleanup_loop(store: DdnsStore, settings: DdnsSettings) -> None: - while True: - await asyncio.sleep(settings.cleanup_interval_seconds) - await run_in_threadpool( - store.cleanup, - settings.cleanup_challenge_hours, - settings.cleanup_unused_account_hours, - ) - - -def _parse_form(body: bytes) -> dict[str, list[str]]: - return urllib.parse.parse_qs(body.decode("utf-8"), keep_blank_values=True) - - -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() - - -def _is_admin_path(path: str) -> bool: - return path == "/admin" or path.startswith("/admin/") or path in {"/records", "/events"} - - -def _is_secret_response(request: Request) -> bool: - path = request.url.path - if _is_admin_path(path) 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' 'unsafe-inline' https://cdn.jsdelivr.net; " - "img-src 'self' data:; " - "font-src 'self' https://cdn.jsdelivr.net; " - "connect-src 'self'; " - "form-action 'self'; " - "frame-ancestors 'none'" - ) - - -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 = html.escape(settings.hostname_suffix or "your DynDNS domain") - demo_update_url = html.escape(f"{settings.public_base_url.rstrip('/')}/u/?myip=") - 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", - f""" -
- {_top_nav()} -
-
- -

KarlDNS for home routers.

-
- {demo_update_url} -
-

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.

- -
-
- {message_html} - {created_html} -
-
-
-

Choose your hostname

-

No account. No login.

-

Use a generated KarlDNS hostname immediately, or verify your own domain with a DNS TXT record.

-
-
-
-
-

Use our domain

-

Generate a KarlDNS hostname

-

KarlDNS generates the hostname, secret update URL, and optional router compatibility credentials automatically.

-
- -
-
-
-
-
-

Use your own domain

-

Verify DNS ownership

-

Add one TXT record so KarlDNS can confirm you control the domain before it publishes A/AAAA records there.

-
- - -
-
-
-
- -
-
-
-
-

Update from anything that can send GET.

-

Use FRITZ!Box placeholders, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, or your own automation.

-
- # explicit IPv4 - curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10' - # source IP fallback - curl -sS 'https://karldns.de/u/<secret>' -
-
-
- {challenge_html} -
- """, - ) - - -def _top_nav(label: str | None = None, links: str = "") -> str: - account_html = "" - if label: - account_html = f'{html.escape(label)}' - if not links: - links = 'DocsAPI' - account_html += 'Generate' - return f""" - - - """ - - -def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> str: - if not challenge: - return "" - challenge_html = "" - create_form = "" - domain = html.escape(str(challenge["domain"])) - token = str(challenge["token"]) - claim_secret = html.escape(str(challenge.get("claim_secret") or "")) - verification_name = service.verification_name(str(challenge["domain"])) - claim_url = service.domain_claim_url(challenge) - status = html.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 = f""" -
-
- 1 -

Add the TXT record

-

{status}

-
TXT name and value are public DNS records. The private claim link is secret.
- {_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")} -
- - - -
-
-
- """ - if challenge.get("verified_at"): - create_form = f""" -
-
- 2 -

Create router hostname

-
- - - -

Router compatibility credentials are generated automatically after creation.

- -
-
-
- """ - else: - create_form = """ -
-
- 2 -

Create router hostname

-

This unlocks after the TXT record is visible in public DNS.

-
-
- """ - - return f""" -
-
-
-

Direct custom-domain publishing

-

Verify DNS ownership

-

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.

-
-
- {challenge_html} - {create_form} -
-
-
- """ - - -def _credentials_panel(service: DdnsService, created_account: dict[str, str] | None) -> str: - if not created_account: - return "" - 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 f""" -
-
-

Hostname generated

-

{html.escape(hostname)}

-

Use the Update-URL directly. The separate Benutzername and Kennwort values are optional compatibility fields for routers that require them.

- {_one_time_secret_notice()} - {_secret_notice()} -
- {_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)} -
-
- {_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")} -
- {_manual_update_panel(manual_commands)} -
-
- """ - - -def _admin_secret_panel(title: str, rows: list[str]) -> str: - return f""" -
-
-

Admin action

-

{html.escape(title)}

-

Copy this now. Secret values are only shown in this response.

-
- {"".join(rows)} -
-
-
- """ - - -def _copy_row(label: str, value: str, sensitivity: str | None = None) -> str: - safe_label = html.escape(label) - safe_value = html.escape(value) - badge = "" - if sensitivity: - safe_sensitivity = html.escape(sensitivity) - badge_class = "text-bg-warning" if sensitivity.lower() == "secret" else "text-bg-secondary" - badge = f'{safe_sensitivity}' - return f""" -
- {safe_label}{badge} - {safe_value} - -
- """ - - -def _copy_button(label: str, value: str) -> str: - safe_label = html.escape(label) - safe_value = html.escape(value) - return f'' - - -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) -> str: - safe_commands = html.escape(commands) - return f""" -
-
-

Manual updates

-

FRITZ!Box replaces <ipaddr> and <ip6addr>, 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.

-
-
{safe_commands}
- {_copy_button("Copy curl examples", commands)} -
- """ - - -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() -> str: - return """ -
- Public: Domainnamen and CNAME target can be shared in DNS. - Secret: Update-URL, one-box update URL, optional Kennwort, private status page, and domain claim links control the hostname. -
- """ - - -def _one_time_secret_notice() -> str: - return """ - - """ - - -def _message_band(message: str | None) -> str: - if not message: - return "" - return f""" -
-
{html.escape(message)}
-
- """ - - -def _render_management_page(service: DdnsService, settings: DdnsSettings, management_slug: str, account: dict[str, str | int | 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 = html.escape(str(account["hostname"])) - username = html.escape(str(account["username"])) - ipv4 = html.escape(str(account.get("ipv4") or "-")) - ipv6 = html.escape(str(account.get("ipv6") or "-")) - updated = html.escape(str(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"]), 20)) or """ - No router updates yet. - """ - return _page( - "DynDNS management", - f""" -
- {_top_nav()} -
-
-

Magic management

-

{hostname}

-

Anyone with this link can manage or delete this hostname. Keep it private.

- Back home -
-
-
-
-
-
-

Router settings

-

Router update settings

-

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.

- {_one_time_secret_notice()} - {_secret_notice()} -
- {_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)} - {_copy_button("Copy CNAME record", cname_record)} -
-
-
- {_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")} -
-
- {_manual_update_panel(manual_commands)} -
-
-
-
-
-

Current address

-

Status

-
- - - - - - -
IPv4{ipv4}
IPv6{ipv6}
Updated{updated}
-
-
-
-
-
-
-

History

-

Router updates

-

Recent IP changes and failed DNS publishes for this hostname.

-
-
-
- - - {history_rows} -
TimeStatusIPv4IPv6Detail
-
-
-
-
-
-
-

Danger zone

-

Delete hostname

-

This removes the hostname and DNS records. The router update URL will stop working.

-
-
- -
-
-
-
- """, - ) - - -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]], - update_events: list[dict[str, str | None]], - notice_html: str | None, -) -> str: - csrf = html.escape(admin_csrf_token(settings)) - rows = "\n".join(_account_row(account, csrf=csrf) for account in accounts) or """ - No hostnames yet. - """ - domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """ - No domain claims yet. - """ - cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """ - No cleanup runs yet. - """ - event_rows = "\n".join(_event_row(event) for event in update_events) or """ - No update events yet. - """ - return _page( - "DynDNS Admin", - f""" -
- {_top_nav("Operator console", 'RecordsEvents')} -
-
-

DynDNS aktiv

-

Operator console

-

Inspect hostnames, verify domain claims, rotate links, run cleanup, and review router update events.

-
-
- - {notice_html or ""} - -
-
-
-
-

Service health

-

Scheduled cleanup

-

Cleanup runs every {html.escape(str(settings.cleanup_interval_seconds))} seconds. It removes unverified domain claims after {html.escape(str(settings.cleanup_challenge_hours))} hours and never-used generated hostnames after {html.escape(str(settings.cleanup_unused_account_hours))} hours.

-
-
- - -
-
-
-
- -
-
-
-
-

Hostnames

-

Active credentials

-
-
- {_admin_table_toolbar("hostnames", "Search hostnames")} -
- - - - - {rows} -
DomainUserIPv4IPv6UpdatedLinks
-
- {_admin_table_pagination()} -
-
- -
-
-
-
-

Domain claims

-

Verification lifecycle

-
-
- {_admin_table_toolbar("domain-claims", "Search domain claims")} -
- - - {domain_rows} -
DomainStatusCreatedVerified
-
- {_admin_table_pagination()} -
-
- -
-
-
-
-

Cleanup

-

Recent runs

-
-
- {_admin_table_toolbar("cleanup-runs", "Search cleanup runs")} -
- - - {cleanup_rows} -
TimeDomain claims removedUnused hostnames removed
-
- {_admin_table_pagination()} -
-
- -
-
-
-
-

Updates

-

Recent events

-
-
- {_admin_table_toolbar("update-events", "Search update events")} -
- - - {event_rows} -
TimeDomainStatusIPv4IPv6Detail
-
- {_admin_table_pagination()} -
-
-
- """, - ) - - -def _account_row(account: dict[str, str | int | None], csrf: str = "") -> str: - hostname = html.escape(str(account["hostname"])) - username = html.escape(str(account["username"])) - ipv4 = html.escape(str(account.get("ipv4") or "-")) - ipv6 = html.escape(str(account.get("ipv6") or "-")) - updated = html.escape(str(account.get("updated_at") or "Never")) - return f""" - - {hostname}{username}{ipv4}{ipv6}{updated} - -
- - - - - -
- - -
- - - -
- - - """ - - -def _admin_table_toolbar(table_id: str, search_label: str) -> str: - safe_id = html.escape(table_id) - safe_label = html.escape(search_label) - return f""" -
-
- -
- 0-0 of 0 - - -
-
- """ - - -def _admin_table_pagination() -> str: - return """ - -
- """ - - -def _domain_claim_row(challenge: dict[str, str | None]) -> str: - domain = html.escape(str(challenge["domain"])) - created = html.escape(str(challenge.get("created_at") or "-")) - verified = html.escape(str(challenge.get("verified_at") or "-")) - status = "Verified" if challenge.get("verified_at") else "Pending" - return f"{domain}{status}{created}{verified}" - - -def _cleanup_row(run: dict[str, str | int]) -> str: - created = html.escape(str(run.get("created_at") or "-")) - domains = html.escape(str(run.get("domain_challenges_deleted") or 0)) - accounts = html.escape(str(run.get("unused_accounts_deleted") or 0)) - return f"{created}{domains}{accounts}" - - -def _event_row(event: dict[str, str | None]) -> str: - created = html.escape(str(event.get("created_at") or "-")) - hostname = html.escape(str(event.get("hostname") or "-")) - status = html.escape(str(event.get("status") or "-")) - ipv4 = html.escape(str(event.get("ipv4") or "-")) - ipv6 = html.escape(str(event.get("ipv6") or "-")) - detail = html.escape(str(event.get("detail") or "-")) - return f"{created}{hostname}{status}{ipv4}{ipv6}{detail}" - - -def _user_event_row(event: dict[str, str | None]) -> str: - created = html.escape(str(event.get("created_at") or "-")) - status = html.escape(str(event.get("status") or "-")) - ipv4 = html.escape(str(event.get("ipv4") or "-")) - ipv6 = html.escape(str(event.get("ipv6") or "-")) - detail = html.escape(str(event.get("detail") or "-")) - return f"{created}{status}{ipv4}{ipv6}{detail}" - - -def _render_privacy_page() -> str: - return _page( - "Legal and Privacy", - f""" -
- {_top_nav()} -
-
-

Legal and privacy

-

KarlDNS keeps accountless DynDNS simple.

-

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.

-
-
-
-
-
-

Data processed

-

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.

-
-
-

Secrets

-

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.

-
-
-

Logs and retention

-

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.

-
-
-

Contact

-

For the public KarlDNS instance, contact Karl through karl.fail or karlcom.de.

-
-
-
-
- """, - ) - - -def _site_footer() -> str: - return """ - - """ - - -def _page(title: str, body: str) -> str: - return f""" - - - - - - - {html.escape(title)} - - - - - - - - - - {body}{_site_footer()} - - """ diff --git a/router_dyndns/ddns_api.py b/router_dyndns/ddns_api.py index f167177..5908393 100644 --- a/router_dyndns/ddns_api.py +++ b/router_dyndns/ddns_api.py @@ -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 .schemas import ( + CustomHostnameRequest, + DomainChallengeRequest, + DomainChallengeResponse, + ErrorResponse, + HostnameCredentials, + MagicHostnameRequest, + ManagedRecord, + RouterUpdateResponse, + VerifyDomainRequest, + VerifyDomainResponse, +) -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"], - ) - - @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", +] diff --git a/router_dyndns/ddns_dns.py b/router_dyndns/ddns_dns.py index 2674c8d..cb28cc2 100644 --- a/router_dyndns/ddns_dns.py +++ b/router_dyndns/ddns_dns.py @@ -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", +] diff --git a/router_dyndns/ddns_models.py b/router_dyndns/ddns_models.py index 1614f81..23c19fe 100644 --- a/router_dyndns/ddns_models.py +++ b/router_dyndns/ddns_models.py @@ -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 diff --git a/router_dyndns/ddns_security.py b/router_dyndns/ddns_security.py index e9571ed..ef99c59 100644 --- a/router_dyndns/ddns_security.py +++ b/router_dyndns/ddns_security.py @@ -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: diff --git a/router_dyndns/ddns_service.py b/router_dyndns/ddns_service.py index 88f36da..33d1087 100644 --- a/router_dyndns/ddns_service.py +++ b/router_dyndns/ddns_service.py @@ -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) + 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: - peer = request.client.host if request.client else None - ipv4 = normalize_ip(peer, version=4) - ipv6 = normalize_ip(peer, version=6) + 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: diff --git a/router_dyndns/ddns_store.py b/router_dyndns/ddns_store.py index ff3fdb1..1030248 100644 --- a/router_dyndns/ddns_store.py +++ b/router_dyndns/ddns_store.py @@ -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"] diff --git a/router_dyndns/dependencies.py b/router_dyndns/dependencies.py new file mode 100644 index 0000000..6424217 --- /dev/null +++ b/router_dyndns/dependencies.py @@ -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) diff --git a/router_dyndns/dns/__init__.py b/router_dyndns/dns/__init__.py new file mode 100644 index 0000000..72910b1 --- /dev/null +++ b/router_dyndns/dns/__init__.py @@ -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", +] diff --git a/router_dyndns/dns/base.py b/router_dyndns/dns/base.py new file mode 100644 index 0000000..2809892 --- /dev/null +++ b/router_dyndns/dns/base.py @@ -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() diff --git a/router_dyndns/dns/cloudflare.py b/router_dyndns/dns/cloudflare.py new file mode 100644 index 0000000..d090de7 --- /dev/null +++ b/router_dyndns/dns/cloudflare.py @@ -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 diff --git a/router_dyndns/dns/rfc2136.py b/router_dyndns/dns/rfc2136.py new file mode 100644 index 0000000..5106f6d --- /dev/null +++ b/router_dyndns/dns/rfc2136.py @@ -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)}") diff --git a/router_dyndns/event_statuses.py b/router_dyndns/event_statuses.py new file mode 100644 index 0000000..f36579a --- /dev/null +++ b/router_dyndns/event_statuses.py @@ -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" diff --git a/router_dyndns/routers/__init__.py b/router_dyndns/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/router_dyndns/routers/admin.py b/router_dyndns/routers/admin.py new file mode 100644 index 0000000..a0ff18b --- /dev/null +++ b/router_dyndns/routers/admin.py @@ -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() diff --git a/router_dyndns/routers/api.py b/router_dyndns/routers/api.py new file mode 100644 index 0000000..45d83e8 --- /dev/null +++ b/router_dyndns/routers/api.py @@ -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 "") diff --git a/router_dyndns/routers/assets.py b/router_dyndns/routers/assets.py new file mode 100644 index 0000000..58571ae --- /dev/null +++ b/router_dyndns/routers/assets.py @@ -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, + ) diff --git a/router_dyndns/routers/management.py b/router_dyndns/routers/management.py new file mode 100644 index 0000000..8471271 --- /dev/null +++ b/router_dyndns/routers/management.py @@ -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( + """ +
+
+
+

Deleted

+

Hostname deleted.

+

The update URL no longer works.

+ Home +
+
+
+ """ + ), + ) + ) + + +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") diff --git a/router_dyndns/routers/management_domains.py b/router_dyndns/routers/management_domains.py new file mode 100644 index 0000000..4eaad14 --- /dev/null +++ b/router_dyndns/routers/management_domains.py @@ -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, + ) diff --git a/router_dyndns/routers/management_passwords.py b/router_dyndns/routers/management_passwords.py new file mode 100644 index 0000000..467da05 --- /dev/null +++ b/router_dyndns/routers/management_passwords.py @@ -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 diff --git a/router_dyndns/routers/management_shared.py b/router_dyndns/routers/management_shared.py new file mode 100644 index 0000000..2999d35 --- /dev/null +++ b/router_dyndns/routers/management_shared.py @@ -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 diff --git a/router_dyndns/routers/public.py b/router_dyndns/routers/public.py new file mode 100644 index 0000000..e576953 --- /dev/null +++ b/router_dyndns/routers/public.py @@ -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() diff --git a/router_dyndns/routers/updates.py b/router_dyndns/routers/updates.py new file mode 100644 index 0000000..0c13d60 --- /dev/null +++ b/router_dyndns/routers/updates.py @@ -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) diff --git a/router_dyndns/schemas.py b/router_dyndns/schemas.py new file mode 100644 index 0000000..aa0b92e --- /dev/null +++ b/router_dyndns/schemas.py @@ -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, + ) diff --git a/router_dyndns/store/__init__.py b/router_dyndns/store/__init__.py new file mode 100644 index 0000000..0d2c3e5 --- /dev/null +++ b/router_dyndns/store/__init__.py @@ -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"] diff --git a/router_dyndns/store/accounts.py b/router_dyndns/store/accounts.py new file mode 100644 index 0000000..e54d683 --- /dev/null +++ b/router_dyndns/store/accounts.py @@ -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" diff --git a/router_dyndns/store/domains.py b/router_dyndns/store/domains.py new file mode 100644 index 0000000..538a123 --- /dev/null +++ b/router_dyndns/store/domains.py @@ -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]) diff --git a/router_dyndns/store/rate_limit.py b/router_dyndns/store/rate_limit.py new file mode 100644 index 0000000..e7937b6 --- /dev/null +++ b/router_dyndns/store/rate_limit.py @@ -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, + } diff --git a/router_dyndns/store/records.py b/router_dyndns/store/records.py new file mode 100644 index 0000000..ba8fe89 --- /dev/null +++ b/router_dyndns/store/records.py @@ -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] diff --git a/router_dyndns/store/scheduler.py b/router_dyndns/store/scheduler.py new file mode 100644 index 0000000..7491f6d --- /dev/null +++ b/router_dyndns/store/scheduler.py @@ -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] diff --git a/router_dyndns/store/schema.py b/router_dyndns/store/schema.py new file mode 100644 index 0000000..78f6db3 --- /dev/null +++ b/router_dyndns/store/schema.py @@ -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}") diff --git a/router_dyndns/store/types.py b/router_dyndns/store/types.py new file mode 100644 index 0000000..49d8ed8 --- /dev/null +++ b/router_dyndns/store/types.py @@ -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 diff --git a/router_dyndns/web/__init__.py b/router_dyndns/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/router_dyndns/web/forms.py b/router_dyndns/web/forms.py new file mode 100644 index 0000000..05df943 --- /dev/null +++ b/router_dyndns/web/forms.py @@ -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() diff --git a/router_dyndns/web/html_utils.py b/router_dyndns/web/html_utils.py new file mode 100644 index 0000000..21bc9dd --- /dev/null +++ b/router_dyndns/web/html_utils.py @@ -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)) diff --git a/router_dyndns/web/security_headers.py b/router_dyndns/web/security_headers.py new file mode 100644 index 0000000..f9dba19 --- /dev/null +++ b/router_dyndns/web/security_headers.py @@ -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'" + ) diff --git a/router_dyndns/web/styles.py b/router_dyndns/web/styles.py new file mode 100644 index 0000000..98e7874 --- /dev/null +++ b/router_dyndns/web/styles.py @@ -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() diff --git a/router_dyndns/web/templates.py b/router_dyndns/web/templates.py new file mode 100644 index 0000000..5319ebf --- /dev/null +++ b/router_dyndns/web/templates.py @@ -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'{escape(label)}' + if links is None: + links = trusted('DocsAPI') + account_html += 'Generate' + theme_toggle = '' + return trusted( + f""" + + + """ + ) + + +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'{safe_sensitivity}' + value_field = ( + f'' + if is_secret + else f'{safe_value}' + ) + reveal_button = ( + f'' + if is_secret + else "" + ) + return trusted( + f""" +
+ {safe_label}{badge} + {value_field} + {reveal_button} + +
+ """ + ) + + +def _copy_button(label: str, value: str) -> SafeHtml: + safe_label = escape(label) + safe_value = attr(value) + return trusted(f'') + + +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""" +
+
+

Manual updates

+

FRITZ!Box replaces <ipaddr> and <ip6addr>, 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.

+
+
{safe_commands}
+ {_copy_button("Copy curl examples", commands)} +
+ """ + ) + + +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( + """ +
+ Public: Domainnamen and CNAME target can be shared in DNS. + Secret: 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. +
+ """ + ) + + +def _one_time_secret_notice() -> SafeHtml: + return trusted( + """ + + """ + ) + + +def _message_band(message: str | None) -> SafeHtml: + if not message: + return trusted("") + return trusted( + f""" +
+
{escape(message)}
+
+ """ + ) + + +def _site_footer() -> SafeHtml: + return trusted( + """ + + """ + ) + + +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""" + + + + + + + {escape(title)} + + + + + + + + + + {body}{_site_footer()} + + """ + + +__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", +] diff --git a/router_dyndns/web/templates_admin.py b/router_dyndns/web/templates_admin.py new file mode 100644 index 0000000..cd64265 --- /dev/null +++ b/router_dyndns/web/templates_admin.py @@ -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""" +
+
+

Admin action

+

{escape(title)}

+

Copy this now. Secret values are only shown in this response.

+
+ {"".join(rows)} +
+
+
+ """) + +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 """ + No hostnames yet. + """ + domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """ + No domain claims yet. + """ + cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """ + No cleanup runs yet. + """ + scheduled_task_rows = "\n".join(_scheduled_task_row(task) for task in scheduled_tasks) or """ + No scheduled tasks yet. + """ + event_rows = "\n".join(_event_row(event) for event in update_events) or """ + No update events yet. + """ + 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""" +
+ {_top_nav("Operator console", trusted(f'RecordsEvents'))} +
+
+

KarlDNS aktiv

+

Operator console

+

Inspect hostnames, verify domain claims, rotate links, run cleanup, and review router update events.

+
+
+ + {notice_html or ""} + +
+
+
+
+

Service health

+

Scheduled cleanup

+

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.

+

The scheduler state is stored in SQLite. Multiple app workers coordinate with a database lease, so only one worker runs a due cleanup task.

+
+
+ + +
+
+
+
+ +
+
+
+
+

Hostnames

+

Active credentials

+

Showing up to {escape(len(accounts))} of {accounts_count} hostnames.

+
+
+ {_admin_table_toolbar("hostnames", "Search hostnames")} +
+ + + + + {rows} +
DomainUserIPv4IPv6UpdatedLinks
+
+ {_admin_table_pagination()} + {account_pager} +
+
+ +
+
+
+
+

Domain claims

+

Verification lifecycle

+

Showing up to {escape(len(domain_challenges))} of {domain_count} claims.

+
+
+ {_admin_table_toolbar("domain-claims", "Search domain claims")} +
+ + + {domain_rows} +
DomainOwner dashboardStatusCreatedVerified
+
+ {_admin_table_pagination()} + {domain_pager} +
+
+ +
+
+
+
+

Cleanup

+

Scheduler state

+
+
+
+ + + {scheduled_task_rows} +
TaskStatusNext runLease expiresLast finishedLast error
+
+
+
+

Cleanup

+

Recent runs

+

Showing up to {escape(len(cleanup_runs))} of {cleanup_count} runs.

+
+
+ {_admin_table_toolbar("cleanup-runs", "Search cleanup runs")} +
+ + + {cleanup_rows} +
TimeDomain claims removedUnused hostnames removed
+
+ {_admin_table_pagination()} + {cleanup_pager} +
+
+ +
+
+
+
+

Updates

+

Recent events

+

Showing up to {escape(len(update_events))} of {event_count} event rows. Repeated unchanged polls are aggregated.

+
+
+ {_admin_table_toolbar("update-events", "Search update events")} +
+ + + {event_rows} +
TimeDomainStatusIPv4IPv6CountDetail
+
+ {_admin_table_pagination()} + {event_pager} +
+
+
+ """), + ) + + +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""" + + """) diff --git a/router_dyndns/web/templates_admin_tables.py b/router_dyndns/web/templates_admin_tables.py new file mode 100644 index 0000000..cc5b31f --- /dev/null +++ b/router_dyndns/web/templates_admin_tables.py @@ -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""" + + {hostname}{username}{ipv4}{ipv6}{updated} + +
+ + + + + +
+ + +
+ + + +
+ + + """ + + +def _admin_table_toolbar(table_id: str, search_label: str) -> str: + safe_id = attr(table_id) + safe_label = attr(search_label) + return f""" +
+
+ +
+ 0-0 of 0 + + +
+
+ """ + + +def _admin_table_pagination() -> str: + return """ + +
+ """ + + +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"{domain}{owner}{status}{created}{verified}" + + +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"{created}{domains}{accounts}" + + +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"{name}{status}{next_run}{lease_expires}{last_finished}{last_error}" + + +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"{created}{hostname}{status}{ipv4}{ipv6}{count}{detail}" diff --git a/router_dyndns/web/templates_management.py b/router_dyndns/web/templates_management.py new file mode 100644 index 0000000..f5fa036 --- /dev/null +++ b/router_dyndns/web/templates_management.py @@ -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 """ + No router updates yet. + """ + 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""" +
+ {_top_nav()} +
+
+

Magic management

+

{hostname}

+

Anyone with this link can manage or delete this hostname. Keep it private.

+ Back home +
+
+ {message_html} + {rotated_credentials} +
+
+
+
+

Router settings

+

Router update settings

+

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.

+ {_one_time_secret_notice()} + {_secret_notice()} +
+ {_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)} + {_copy_button("Copy CNAME record", cname_record)} +
+
+
+ {_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")} +
+
+ {_manual_update_panel(manual_commands)} +
+
+

Actions

+

Rotate dashboard secrets

+

Rotate the URL used by routers, or issue a new router password for compatibility clients. Update your router after rotating.

+
+
+
+ + + +
+
+ + + +
+
+
+
+
+ {domain_claim} + {dashboard_password_panel} +
+
+
+

Current address

+

Status

+
+
+ + + + + + +
IPv4{ipv4}
IPv6{ipv6}
Updated{updated}
+
+
+
+
+
+
+
+

History

+

Router updates

+

Recent IP changes and failed DNS publishes for this hostname.

+
+
+
+ + + {history_rows} +
TimeStatusIPv4IPv6CountDetail
+
+
+
+
+
+
+

Danger zone

+

Delete hostname

+

This removes the hostname and DNS records. The router update URL will stop working.

+
+
+ + +
+
+
+
+ """), + ) + +def _management_message(message: str | None) -> SafeHtml: + if not message: + return trusted("") + return trusted( + f""" +
+
{escape(message)}
+
+ """ + ) + + +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""" +
+ + + + +
+ """ + current_password_field = "" + if password_enabled: + current_password_field = """ +
+ + +
+ """ + return trusted( + f""" +
+
+
+
+

Dashboard password

+

Password protection

+

Status: {escape(password_status)}. This protects this management dashboard only. Router update URLs continue to work normally.

+
+
+
+ + {current_password_field} +
+ + +
+
+ + +
+
+ +
+
+ {remove_form} +
+
+
+
+ """ + ) + + +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'' + return _page( + "Unlock KarlDNS management", + trusted(f""" +
+ {_top_nav()} +
+
+

Dashboard locked

+

{hostname}

+

Enter the dashboard password to manage this hostname.

+
+
+
+
+ {message_html} +
+ +
+ + +
+ + +
+
+
+
+ """), + ) + + +def _management_domain_claim_panel( + service: DdnsService, + management_slug: str, + csrf_token: str, + challenge: dict[str, str | None] | None, +) -> SafeHtml: + create_claim = trusted( + f""" +
+
+

Custom domain

+

Claim a domain

+

Create a TXT challenge, verify it from this dashboard, then create router credentials for a hostname under that verified domain.

+
+
+ + + +
+
+ """ + ) + if not challenge: + return trusted( + f""" +
+
{create_claim}
+
+ """ + ) + + 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""" +
+
+ {create_claim} +
+
+

Domain claim

+

{escape(domain)}

+

Status: {escape(status)}

+

This claim belongs to this dashboard. Another dashboard cannot verify or use it.

+
+
TXT name and value are public DNS records. The claim secret and private claim link are secrets.
+
+ {_copy_button("Copy TXT record", txt_bundle)} +
+
+ {_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")} +
+
+ + + + +
+ {create_hostname} +
+
+
+ """ + ) + + +def _management_custom_hostname_form(management_slug: str, claim_secret: str, csrf_token: str | None = None) -> SafeHtml: + csrf_field = f'' if csrf_token else "" + return trusted( + f""" +
+ {csrf_field} + + + +
+ """ + ) + + +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"{created}{status}{ipv4}{ipv6}{count}{detail}" diff --git a/router_dyndns/web/templates_public.py b/router_dyndns/web/templates_public.py new file mode 100644 index 0000000..218e7b8 --- /dev/null +++ b/router_dyndns/web/templates_public.py @@ -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/?myip=") + 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""" +
+ {_top_nav()} +
+
+ +

KarlDNS for home routers.

+
+ {demo_update_url} +
+

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.

+ +
+
+ {message_html} + {created_html} +
+
+
+

Choose your hostname

+

No account. No login.

+

Use a generated KarlDNS hostname immediately, or verify your own domain with a DNS TXT record.

+
+
+
+
+

Use our domain

+

Generate a KarlDNS hostname

+

KarlDNS generates the hostname, secret update URL, and optional router compatibility credentials automatically.

+
+ +
+
+
+
+
+

Use your own domain

+

Verify DNS ownership

+

Add one TXT record so KarlDNS can confirm you control the domain before it publishes A/AAAA records there.

+
+ + +
+
+
+
+ +
+
+
+
+

Update from anything that can send GET.

+

Use FRITZ!Box placeholders, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, or your own automation.

+
+ # explicit IPv4 + curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10' + # source IP fallback + curl -sS 'https://karldns.de/u/<secret>' +
+
+
+ {_linux_installer_section()} + {challenge_html} +
+ """), + ) + +def _render_privacy_page() -> str: + return _page( + "Legal and Privacy", + trusted(f""" +
+ {_top_nav()} +
+
+

Legal and privacy

+

KarlDNS keeps accountless DynDNS simple.

+

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.

+
+
+
+
+
+

Data processed

+

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.

+
+
+

Secrets

+

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.

+
+
+

Logs and retention

+

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.

+
+
+

Contact

+

For the public KarlDNS instance, contact Karl through karl.fail or karlcom.de.

+
+
+
+
+ """), + ) diff --git a/router_dyndns/web/templates_public_sections.py b/router_dyndns/web/templates_public_sections.py new file mode 100644 index 0000000..df7506f --- /dev/null +++ b/router_dyndns/web/templates_public_sections.py @@ -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""" +
+
+
+

Linux cron updater

+

Install and uninstall should be boring.

+

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.

+
+
+
+ {escape(system_install)} + {escape(system_uninstall)} +
+
+ {_copy_button("Copy install", system_install)} + {_copy_button("Copy uninstall", system_uninstall)} + {_copy_button("Copy all commands", all_commands)} +
+
+ Uninstall keeps the private config by default because it may contain your only local copy of the update and dashboard URLs. Use --delete-config for a clean wipe. +
+
+

Without sudo

+

User mode writes to ~/.local/bin, ~/.config/karldns, and your user crontab.

+
+
+ {escape(user_install)} + {escape(clean_uninstall)} +
+
+ {_copy_button("Copy user install", user_install)} + {_copy_button("Copy clean user uninstall", clean_uninstall)} +
+
+
+
+ """ + ) + + +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""" +
+
+ 1 +

Add the TXT record

+

{status}

+
TXT name and value are public DNS records. The private claim link is secret.
+ {_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")} +
+ + + +
+
+
+ """ + ) + create_form = _custom_hostname_form(claim_secret) if challenge.get("verified_at") else _locked_custom_hostname_card() + + return trusted( + f""" +
+
+
+

Direct custom-domain publishing

+

Verify DNS ownership

+

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.

+
+
+ {challenge_html} + {create_form} +
+
+
+ """ + ) + + +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""" +
+
+

Hostname generated

+

{escape(hostname)}

+

Use the Update-URL directly. The separate Benutzername and Kennwort values are optional compatibility fields for routers that require them.

+ {_one_time_secret_notice()} + {_secret_notice()} +
+ {_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)} +
+
+ {_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")} +
+ {_manual_update_panel(manual_commands)} +
+
+ """ + ) + + +def _custom_hostname_form(claim_secret: str) -> SafeHtml: + return trusted( + f""" +
+
+ 2 +

Create router hostname

+
+ + + +

Router compatibility credentials are generated automatically after creation.

+ +
+
+
+ """ + ) + + +def _locked_custom_hostname_card() -> SafeHtml: + return trusted( + """ +
+
+ 2 +

Create router hostname

+

This unlocks after the TXT record is visible in public DNS.

+
+
+ """ + ) diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh new file mode 100755 index 0000000..f799fd1 --- /dev/null +++ b/scripts/install-linux.sh @@ -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/ 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" </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 + *""*|*""*) 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 diff --git a/scripts/karldns-update.sh b/scripts/karldns-update.sh new file mode 100755 index 0000000..602c513 --- /dev/null +++ b/scripts/karldns-update.sh @@ -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 + *""*|*""*) 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 diff --git a/scripts/uninstall-linux.sh b/scripts/uninstall-linux.sh new file mode 100755 index 0000000..c22dc4a --- /dev/null +++ b/scripts/uninstall-linux.sh @@ -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 diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..08b5aff --- /dev/null +++ b/tests/test_admin.py @@ -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", + '', + "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 '' not in response.text + assert "<script>alert("xss")</script>" in response.text diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..f25ec0e --- /dev/null +++ b/tests/test_api.py @@ -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"] == [""] + assert params["myipv6"] == [""] + + +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" diff --git a/tests/test_ddns.py b/tests/test_ddns.py deleted file mode 100644 index c611a79..0000000 --- a/tests/test_ddns.py +++ /dev/null @@ -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"] == [""] - assert params["myipv6"] == [""] - - -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/<secret>" 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 '' 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 diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..7324bec --- /dev/null +++ b/tests/test_scheduler.py @@ -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 diff --git a/tests/test_scripts.py b/tests/test_scripts.py new file mode 100644 index 0000000..e544e6a --- /dev/null +++ b/tests/test_scripts.py @@ -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' ;; + *''*|*''*) 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 "" not in log_file.read_text(encoding="utf-8") diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..609c899 --- /dev/null +++ b/tests/test_security.py @@ -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") diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..47598e8 --- /dev/null +++ b/tests/test_ui.py @@ -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/<secret>" 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 '' in response.text + assert '' in response.text + assert "