diff --git a/.env.example b/.env.example index 63c593f..7febcde 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Required for a public KarlDNS instance. DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password DDNS_ADMIN_PATH=/admin +DDNS_DASHBOARD_COOKIE_SECRET=change-me-long-random-cookie-signing-secret DDNS_PUBLIC_BASE_URL=https://karldns.de DDNS_HOSTNAME_SUFFIX=karldns.de @@ -23,6 +24,7 @@ DDNS_TTL=60 # DDNS_RATE_LIMIT_PER_MINUTE=60 # DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20 # DDNS_MAX_REQUEST_BODY_BYTES=16384 +# DDNS_REDACTED_ACCESS_LOG=0 # DDNS_CLEANUP_CHALLENGE_HOURS=72 # DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080 # DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320 diff --git a/.gitignore b/.gitignore index dbe15f0..2ddc1cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ .pytest_cache/ .ruff_cache/ +.playwright-cli/ *.sqlite3 *.sqlite3-* *.db diff --git a/README.md b/README.md index 46c663a..eca81c8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # KarlDNS -**Self-hosted DynDNS for FRITZ!Box, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and any router that can call a custom DDNS update URL.** +**Self-hosted Dynamic DNS (DynDNS / DDNS) for FRITZ!Box, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, Linux cron jobs, and any router that can call a custom DDNS update URL.** -KarlDNS is the friendly name for `karldns`: a small FastAPI Dynamic DNS provider you can run on your own VPS. It gives users FRITZ!Box-ready DynDNS credentials, keeps public A/AAAA records updated through Cloudflare or RFC 2136, provides generated hostnames that work as CNAME targets, and supports verified custom domains with DNS TXT ownership checks. +KarlDNS is the friendly name for `karldns`: a small FastAPI Dynamic DNS provider you can run on your own VPS. It gives users accountless private update URLs, FRITZ!Box-ready DynDNS fields, public A/AAAA records through Cloudflare or RFC 2136, generated hostnames that work as CNAME targets, and verified custom domains with DNS TXT ownership checks. If you want a lightweight DuckDNS / Dynu / No-IP style service that you control yourself, KarlDNS is built for that use case. @@ -21,7 +21,7 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control ## Screenshots -### Generate Router Credentials +### Create Router Credentials ![Generated FRITZ!Box DynDNS credentials](docs/screenshots/generated-credentials.png) @@ -29,6 +29,19 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control ![DNS TXT verification flow for custom domains](docs/screenshots/domain-verification.png) +## Logo And Social Preview + +KarlDNS ships web-ready brand assets so self-hosted instances and shared links look clean out of the box: + +| Asset | Purpose | +| --- | --- | +| `router_dyndns/assets/logo-nav.webp` | Small navigation logo. | +| `router_dyndns/assets/logo.webp` | Optimized homepage logo. | +| `router_dyndns/assets/logo-192.png` and `logo-512.png` | PWA, app icon, and manifest icons. | +| `router_dyndns/assets/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, `apple-touch-icon.png` | Browser and mobile icons. | +| `router_dyndns/assets/social-preview.png` | Open Graph and Twitter large-card preview for reliable crawlers. | +| `router_dyndns/assets/social-preview.webp` | Optimized WebP social preview for modern clients. | + ## What It Does KarlDNS lets you host your own managed DynDNS service: @@ -107,6 +120,7 @@ For an internet-facing DynDNS service, run behind HTTPS and require a real DNS b ```bash export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value' export DDNS_ADMIN_PATH='/admin' +export DDNS_DASHBOARD_COOKIE_SECRET='replace-with-a-32-plus-character-random-value' export DDNS_PUBLIC_BASE_URL='https://karldns.de' export DDNS_HOSTNAME_SUFFIX='karldns.de' export DDNS_DATABASE='/data/ddns.sqlite3' @@ -146,7 +160,7 @@ KarlDNS also shows optional compatibility fields for routers that insist on sepa - `Benutzername` (optional) - `Kennwort` (optional) -Copy the router password and one-box update URL when they are generated or rotated. They are intentionally not recoverable from the dashboard later. +Copy the router password and full update URL when they are generated or rotated. They are intentionally not recoverable from the dashboard later. The generated FRITZ!Box URL uses the native placeholders: @@ -156,7 +170,7 @@ https://karldns.de/u/?myip=&myipv6= FRITZ!Box replaces `` and `` with the current WAN IPv4/IPv6 address and sends an HTTP GET request to that URL whenever it detects an address change. KarlDNS returns DynDNS-style responses such as `good 203.0.113.10`, `nochg 203.0.113.10`, `badauth`, or `nohost`. -This URL is enough for routers that only expose a single update URL field and do not send separate credentials. KarlDNS also shows a `One-box update URL` that embeds `hostname`, `username`, and `password` as query parameters for clients that require credential-style DynDNS URLs but do not expose separate credential fields. Treat both URLs as secrets. +This URL is enough for routers that only expose a single update URL field and do not send separate credentials. KarlDNS also shows a `Full update URL` that embeds `hostname`, `username`, and `password` as query parameters for clients that require credential-style DynDNS URLs but do not expose separate credential fields. Treat both URLs as secrets. The compatibility endpoint is also available: @@ -268,7 +282,7 @@ 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: +Most users do not need domain verification. Create a KarlDNS hostname first, then create a CNAME at your DNS provider: ```dns home.example.com. CNAME a1b2c3d4.karldns.de. @@ -278,7 +292,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. 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. +The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, router password, private dashboard 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 @@ -325,7 +339,7 @@ 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 example environment is intentionally short: admin password/path, dashboard cookie signing secret, 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`. @@ -338,6 +352,7 @@ The default container command runs one Uvicorn worker. Keep that deployment shap - Run behind HTTPS. - Keep `.env` out of git. - Use a long random value for `DDNS_ADMIN_PASSWORD`. +- Use a separate long random value for `DDNS_DASHBOARD_COOKIE_SECRET` on public instances. - 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` only when the app should answer for hostnames beyond `DDNS_PUBLIC_BASE_URL`. @@ -346,7 +361,7 @@ The default container command runs one Uvicorn worker. Keep that deployment shap - 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 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. +- Keep Uvicorn access logs disabled. Use `DDNS_REDACTED_ACCESS_LOG=1` or `karldns serve --access-log` only for KarlDNS' built-in redacted access log, which masks bearer URL paths and query strings. Security and cleanup defaults: @@ -356,6 +371,8 @@ Security and cleanup defaults: | `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_DASHBOARD_COOKIE_SECRET` | `DDNS_ADMIN_PASSWORD` fallback | HMAC signing secret for dashboard unlock cookies. Use a separate 32+ character random value in production. | +| `DDNS_REDACTED_ACCESS_LOG` | `0` | Enable KarlDNS request logs with bearer paths and update query strings redacted. | | `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. | diff --git a/docs/bootstrap-subset.md b/docs/bootstrap-subset.md new file mode 100644 index 0000000..20f50fa --- /dev/null +++ b/docs/bootstrap-subset.md @@ -0,0 +1,42 @@ +# Bootstrap Subset Build Proposal + +KarlDNS currently serves the local Bootstrap 5.3.8 asset at `/bootstrap-5.3.8.min.css`. The local full file is 226.7 KiB raw and 30.1 KiB gzip. + +## Current Bootstrap Surface + +Static templates and asset JavaScript use a small Bootstrap surface: + +- Layout: `container`, `row`, `col-*`, `d-flex`, `d-grid`, `gap-*`, responsive flex alignment, spacing utilities, `w-100`, `h-100`. +- Components: `alert`, `badge`, `btn`, `card`, `form-control`, `form-select`, `form-check`, `input-group`, `nav`, `navbar`, `pagination`, `table`. +- Helpers/utilities: `border*`, `rounded*`, `text-*`, `text-bg-*`, `text-truncate`, `visually-hidden`, `opacity-75`, `small`. +- JavaScript-created classes in `router_dyndns/routers/assets.py`: `page-item`, `disabled`, `active`, `page-link`, `empty-row`, `empty`. + +The app CSS in `router_dyndns/web/styles.py` already owns the custom KarlDNS look. Bootstrap is mainly supplying reset, grid, forms, buttons, tables, navigation, pagination, and common utilities. + +## Proposed Build + +Use `scripts/build-bootstrap-subset.py` to compile a Bootstrap Sass subset from transient npm packages: + +```sh +python3 scripts/build-bootstrap-subset.py +python3 scripts/build-bootstrap-subset.py --write +``` + +The script imports Bootstrap 5.3.8 Sass modules for the components above and filters `$utilities` before `utilities/api` so unused utility families are not emitted. It does not require a committed `package.json`, lockfile, or `node_modules`. + +Dry-run result on this worktree: + +- Subset: 113.8 KiB raw, 16.8 KiB gzip. +- Full local asset: 226.7 KiB raw, 30.1 KiB gzip. +- Reduction: 49.8% raw, 44.2% gzip. + +## Rollout Recommendation + +Keep the current app references unchanged until the subset is visually tested. The safe implementation sequence is: + +1. Run `python3 scripts/build-bootstrap-subset.py --write` to create `router_dyndns/assets/bootstrap-karldns.min.css`. +2. Add an isolated asset route for `/bootstrap-karldns.min.css`. +3. Switch `_page()` from `/bootstrap-5.3.8.min.css?v=2` to `/bootstrap-karldns.min.css?v=1`. +4. Run UI tests and browser-check public, dashboard, locked dashboard, admin tables, pagination, dark mode, and copy fields. + +Do not use a selector-purge pass against rendered HTML yet. The Python templates include dynamic branches and JavaScript-created pagination classes, so Sass module selection plus a curated utility allowlist is simpler and less likely to break rare pages. diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png index ba582f3..bb0b280 100644 Binary files a/docs/screenshots/home.png and b/docs/screenshots/home.png differ diff --git a/router_dyndns/app.py b/router_dyndns/app.py index 8adddc0..cbc3008 100644 --- a/router_dyndns/app.py +++ b/router_dyndns/app.py @@ -11,7 +11,7 @@ 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_dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns from .ddns_models import DdnsSettings, UpdateResult from .ddns_service import DdnsService from .ddns_store import DdnsStore @@ -23,6 +23,7 @@ __all__ = [ "DdnsStore", "DnsConfigurationError", "DnsProviderError", + "DnsReconciliationError", "UpdateResult", "delete_dns_records", "make_app", @@ -33,7 +34,7 @@ __all__ = [ OPENAPI_TAGS = [ { "name": "hostnames", - "description": "Create provider-owned or verified custom DynDNS hostnames.", + "description": "Create KarlDNS hostnames or hostnames under verified domains.", }, { "name": "domains", @@ -45,7 +46,7 @@ OPENAPI_TAGS = [ }, { "name": "management", - "description": "Bearer-link JSON management for generated hostnames. Dashboard passwords protect only the web dashboard, not these API routes.", + "description": "API management through private dashboard links. Dashboard passwords protect only the web dashboard, not these API routes.", }, ] @@ -142,6 +143,7 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None: ) except Exception as exc: LOGGER.exception("Scheduled cleanup failed") + await run_in_threadpool(store.increment_metric, "cleanup_errors_total") await run_in_threadpool( store.complete_scheduled_task, CLEANUP_TASK_NAME, @@ -157,4 +159,5 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None: True, "", ) + await run_in_threadpool(store.increment_metric, "cleanup_success_total") await run_in_threadpool(store.optimize) diff --git a/router_dyndns/assets/bootstrap-subset.css b/router_dyndns/assets/bootstrap-subset.css new file mode 100644 index 0000000..34b1a6a --- /dev/null +++ b/router_dyndns/assets/bootstrap-subset.css @@ -0,0 +1 @@ +:root{--bs-body-color:#000;--bs-body-bg:#fff;--bs-border-color:#e5e5e5;--bs-secondary-color:#737373}[data-bs-theme="dark"]{--bs-body-color:#f5f5f5;--bs-body-bg:#0f0f0f;--bs-border-color:rgba(255,255,255,.16);--bs-secondary-color:#a3a3a3}html{-webkit-text-size-adjust:100%}body{margin:0}button,input,select{margin:0;font:inherit}button,select{text-transform:none}button:not(:disabled),[type="button"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}a{color:inherit}img,svg{vertical-align:middle}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}pre{margin:0}.container{width:100%;margin-right:auto;margin-left:auto;padding-right:12px;padding-left:12px}.row{display:flex;flex-wrap:wrap;margin-right:calc(-.5 * var(--bs-gutter-x,0));margin-left:calc(-.5 * var(--bs-gutter-x,0));margin-top:calc(-1 * var(--bs-gutter-y,0))}.row>*{width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x,0) * .5);padding-left:calc(var(--bs-gutter-x,0) * .5);margin-top:var(--bs-gutter-y,0)}.col-6{flex:0 0 auto;width:50%}.col-12{flex:0 0 auto;width:100%}@media (min-width:768px){.col-md-6{flex:0 0 auto;width:50%}.align-items-md-center{align-items:center!important}.flex-md-row{flex-direction:row!important}}@media (min-width:992px){.col-lg-3{flex:0 0 auto;width:25%}.col-lg-5{flex:0 0 auto;width:41.666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.333333%}.flex-lg-row{flex-direction:row!important}}@media (min-width:1200px){.col-xl-3{flex:0 0 auto;width:25%}}.g-3{--bs-gutter-x:16px;--bs-gutter-y:16px}.g-4{--bs-gutter-x:24px;--bs-gutter-y:24px}.d-flex{display:flex!important}.d-grid{display:grid!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-wrap{flex-wrap:wrap!important}.flex-shrink-0{flex-shrink:0!important}.align-items-center{align-items:center!important}.align-items-start{align-items:flex-start!important}.align-items-stretch{align-items:stretch!important}.align-self-start{align-self:flex-start!important}.justify-content-between{justify-content:space-between!important}.justify-content-center{justify-content:center!important}.justify-content-end{justify-content:flex-end!important}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.gap-sm-3{gap:16px!important}}.gap-2{gap:8px!important}.gap-3{gap:16px!important}.gap-4{gap:24px!important}.m-0{margin:0!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.ms-auto{margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-3{margin-top:16px!important;margin-bottom:16px!important}.mt-1{margin-top:4px!important}.mt-2{margin-top:8px!important}.mt-3{margin-top:16px!important}.mt-4{margin-top:24px!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:4px!important}.mb-2{margin-bottom:8px!important}.mb-3{margin-bottom:16px!important}.mb-4{margin-bottom:24px!important}.p-0{padding:0!important}.p-4{padding:24px!important}.px-0{padding-right:0!important;padding-left:0!important}.px-2{padding-right:8px!important;padding-left:8px!important}.px-3{padding-right:16px!important;padding-left:16px!important}.py-2{padding-top:8px!important;padding-bottom:8px!important}.w-100{width:100%!important}.h-100{height:100%!important}.text-center{text-align:center!important}.text-start{text-align:left!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-secondary{color:var(--bs-secondary-color)!important}.text-danger{color:var(--rp-danger)!important}.small{font-size:.875em!important}.h5{font-size:1.25rem}.opacity-75{opacity:.75!important}.rounded{border-radius:12px!important}.rounded-0{border-radius:0!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:9999px!important}.border{border:1px solid var(--bs-border-color)!important}.border-top{border-top:1px solid var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-end-0{border-right:0!important}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:5px;padding-bottom:5px;margin-right:16px;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-text{padding-top:8px;padding-bottom:8px}.nav-link{display:block;padding:8px;text-decoration:none}.btn{--bs-btn-color:#000;--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-btn-color);--bs-btn-hover-bg:var(--bs-btn-bg);--bs-btn-hover-border-color:var(--bs-btn-border-color);--bs-btn-active-color:var(--bs-btn-hover-color);--bs-btn-active-bg:var(--bs-btn-hover-bg);--bs-btn-active-border-color:var(--bs-btn-hover-border-color);color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border:1px solid var(--bs-btn-border-color);text-decoration:none;user-select:none}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn:disabled,.btn.disabled{pointer-events:none;opacity:.65}.form-label{margin-bottom:8px}.form-control,.form-select{display:block;appearance:none}.form-select{padding:8px 40px 8px 16px}.form-select-sm{min-height:34px;padding-top:4px;padding-bottom:4px;font-size:.875rem}.form-check{display:block;min-height:24px}.form-check-input{width:1em;height:1em;margin:0}.form-check-label{color:inherit;font-size:inherit}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group-text{display:flex;align-items:center;padding:10px 16px;border:1px solid var(--bs-border-color)}.card{position:relative;min-width:0}.card-body{flex:1 1 auto}.flat-card{box-shadow:none}.badge{display:inline-block;padding:4px 8px;font-size:.75em;font-weight:600;line-height:1;text-align:center;white-space:nowrap}.alert{position:relative;padding:16px;border:1px solid transparent}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}.table{width:100%;margin-bottom:16px;vertical-align:top;border-collapse:collapse}.table>:not(caption)>*>*{padding:8px}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;min-height:36px;padding:8px 12px;color:var(--rp-ink);background:var(--rp-surface);border:1px solid var(--rp-line)}.page-item:first-child .page-link{border-top-left-radius:999px;border-bottom-left-radius:999px}.page-item:last-child .page-link{border-top-right-radius:999px;border-bottom-right-radius:999px}.page-item.active .page-link{color:#fff;background:#000;border-color:#000}.page-item.disabled .page-link,.page-link:disabled{color:var(--rp-muted);pointer-events:none;background:var(--rp-field)}.pagination-sm .page-link{min-height:32px;padding:6px 10px;font-size:.875rem}.visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.empty-row{} diff --git a/router_dyndns/assets/favicon.ico b/router_dyndns/assets/favicon.ico index 368533f..261a237 100644 Binary files a/router_dyndns/assets/favicon.ico and b/router_dyndns/assets/favicon.ico differ diff --git a/router_dyndns/assets/logo-nav.webp b/router_dyndns/assets/logo-nav.webp new file mode 100644 index 0000000..415aeeb Binary files /dev/null and b/router_dyndns/assets/logo-nav.webp differ diff --git a/router_dyndns/assets/logo.png b/router_dyndns/assets/logo.png index 32f887a..70167dd 100644 Binary files a/router_dyndns/assets/logo.png and b/router_dyndns/assets/logo.png differ diff --git a/router_dyndns/assets/logo.webp b/router_dyndns/assets/logo.webp new file mode 100644 index 0000000..5c2b943 Binary files /dev/null and b/router_dyndns/assets/logo.webp differ diff --git a/router_dyndns/assets/social-preview.png b/router_dyndns/assets/social-preview.png new file mode 100644 index 0000000..b4a1732 Binary files /dev/null and b/router_dyndns/assets/social-preview.png differ diff --git a/router_dyndns/assets/social-preview.webp b/router_dyndns/assets/social-preview.webp new file mode 100644 index 0000000..de11e2e Binary files /dev/null and b/router_dyndns/assets/social-preview.webp differ diff --git a/router_dyndns/cli.py b/router_dyndns/cli.py index 8f6e12c..71404a6 100644 --- a/router_dyndns/cli.py +++ b/router_dyndns/cli.py @@ -9,18 +9,18 @@ def main() -> None: parser = argparse.ArgumentParser(prog="karldns") subparsers = parser.add_subparsers(dest="command", required=True) - render_url = subparsers.add_parser("render-fritz-url", help="Render a FRITZ!Box custom DynDNS update URL.") - render_url.add_argument("--base-url", required=True, help="Public base URL of this DDNS service.") + render_url = subparsers.add_parser("render-fritz-url", help="Print a FRITZ!Box DynDNS update URL.") + render_url.add_argument("--base-url", required=True, help="Public KarlDNS URL.") render_url.add_argument("--host", required=True, help="DNS hostname configured in FRITZ!Box.") render_url.add_argument("--include-ipv6", action="store_true") - serve = subparsers.add_parser("serve", help="Run the DDNS HTTP service.") + serve = subparsers.add_parser("serve", help="Run the KarlDNS web 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.", + help="Enable redacted request logs. Raw web-server access logs stay off because private URLs are secrets.", ) args = parser.parse_args() @@ -31,8 +31,10 @@ 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, access_log=args.access_log) + raise SystemExit("Set DDNS_ADMIN_PASSWORD before starting KarlDNS.") + if args.access_log: + settings = settings.model_copy(update={"redacted_access_log": True}) + uvicorn.run(make_app(settings), host=args.host, port=args.port, access_log=False) def _render_fritz_url(base_url: str, host: str, include_ipv6: bool) -> str: diff --git a/router_dyndns/config.py b/router_dyndns/config.py index 1f5a57f..487c28e 100644 --- a/router_dyndns/config.py +++ b/router_dyndns/config.py @@ -39,6 +39,8 @@ def settings_from_env() -> DdnsSettings: "DDNS_DASHBOARD_STAY_UNLOCKED_SECONDS", 30 * 24 * 60 * 60, ), + dashboard_cookie_secret=os.getenv("DDNS_DASHBOARD_COOKIE_SECRET", ""), + redacted_access_log=_env_flag("DDNS_REDACTED_ACCESS_LOG"), ttl=_env_int("DDNS_TTL", 60), ) diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index e518adb..1fd25bb 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -1,7 +1,7 @@ from __future__ import annotations from .app import make_app -from .ddns_dns import DnsConfigurationError, DnsProviderError, delete_dns_records, publish_dns +from .ddns_dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns from .ddns_models import DdnsSettings, UpdateResult from .ddns_store import DdnsStore @@ -10,6 +10,7 @@ __all__ = [ "DdnsStore", "DnsConfigurationError", "DnsProviderError", + "DnsReconciliationError", "UpdateResult", "delete_dns_records", "make_app", diff --git a/router_dyndns/ddns_dns.py b/router_dyndns/ddns_dns.py index cb28cc2..6c5753f 100644 --- a/router_dyndns/ddns_dns.py +++ b/router_dyndns/ddns_dns.py @@ -3,6 +3,7 @@ from __future__ import annotations from .dns import ( DnsConfigurationError, DnsProviderError, + DnsReconciliationError, delete_cloudflare_records, delete_dns_records, delete_rfc2136_records, @@ -16,6 +17,7 @@ from .dns import ( __all__ = [ "DnsConfigurationError", "DnsProviderError", + "DnsReconciliationError", "delete_cloudflare_records", "delete_dns_records", "delete_rfc2136_records", diff --git a/router_dyndns/ddns_models.py b/router_dyndns/ddns_models.py index 23c19fe..9511da9 100644 --- a/router_dyndns/ddns_models.py +++ b/router_dyndns/ddns_models.py @@ -5,10 +5,12 @@ import urllib.parse from dataclasses import dataclass from pathlib import Path -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator class DdnsSettings(BaseModel): + model_config = ConfigDict(extra="forbid") + database_path: Path = Field(default=Path("ddns.sqlite3")) shared_secret: str = Field(default="") admin_password: str = Field(default="") @@ -37,6 +39,8 @@ class DdnsSettings(BaseModel): 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) + dashboard_cookie_secret: str = Field(default="") + redacted_access_log: bool = Field(default=False) ttl: int = Field(default=60, ge=60, le=86400) @field_validator("public_base_url") @@ -61,16 +65,22 @@ class DdnsSettings(BaseModel): raise ValueError("admin_path may only contain URL-safe path characters") reserved_paths = { "/accounts", + "/admin.css", + "/admin-tables.js", "/api", "/app.js", "/claim", + "/clipboard.js", "/d", "/docs", "/favicon.ico", "/healthz", "/install.sh", "/ip", + "/bootstrap-subset.css", "/logo.png", + "/logo-nav.webp", + "/logo.webp", "/m", "/magic", "/nic", @@ -78,7 +88,11 @@ class DdnsSettings(BaseModel): "/privacy", "/redoc", "/robots.txt", + "/sitemap.xml", "/site.webmanifest", + "/social-preview.png", + "/social-preview.webp", + "/theme.js", "/u", "/uninstall.sh", } @@ -104,6 +118,10 @@ class DdnsSettings(BaseModel): return if len(self.admin_password) < 16: raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1") + if len(self.dashboard_cookie_secret or self.admin_password) < 32: + raise RuntimeError( + "DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1" + ) if not self.hostname_suffix: raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1") if not self.effective_trusted_hosts: diff --git a/router_dyndns/ddns_security.py b/router_dyndns/ddns_security.py index ef99c59..b295bf1 100644 --- a/router_dyndns/ddns_security.py +++ b/router_dyndns/ddns_security.py @@ -53,17 +53,31 @@ def sign_dashboard_unlock_cookie( hostname: str, management_slug: str, password_hash: str, + signing_secret: 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}" + signature = _dashboard_unlock_signature( + hostname, + management_slug, + password_hash, + signing_secret, + timestamp, + bounded_max_age, + ) + return f"v3:{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: +def verify_dashboard_unlock_cookie( + hostname: str, + management_slug: str, + password_hash: str | None, + signing_secret: str, + cookie_value: str | None, +) -> bool: + if not password_hash or not signing_secret or not cookie_value: return False try: version, raw_timestamp, raw_max_age, supplied = cookie_value.split(":", 3) @@ -71,12 +85,19 @@ def verify_dashboard_unlock_cookie(hostname: str, management_slug: str, password max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age)) except ValueError: return False - if version != "v2": + if version != "v3": 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) + expected = _dashboard_unlock_signature( + hostname, + management_slug, + password_hash, + signing_secret, + timestamp, + max_age_seconds, + ) return hmac.compare_digest(supplied, expected) @@ -84,11 +105,16 @@ def _dashboard_unlock_signature( hostname: str, management_slug: str, password_hash: str, + signing_secret: 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() + payload = f"{hostname}\0{management_slug}\0{password_hash}\0{timestamp}\0{max_age_seconds}".encode() + return hmac.new(signing_secret.encode(), payload, hashlib.sha256).hexdigest() + + +def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str: + return settings.dashboard_cookie_secret or settings.admin_password def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | None]) -> str: diff --git a/router_dyndns/ddns_service.py b/router_dyndns/ddns_service.py index 33d1087..d83ab5d 100644 --- a/router_dyndns/ddns_service.py +++ b/router_dyndns/ddns_service.py @@ -6,16 +6,17 @@ import re import secrets import sqlite3 import threading +import time import urllib.parse from dataclasses import dataclass from fastapi import HTTPException, Request -from .ddns_dns import DnsProviderError, delete_dns_records, dns_backend_configured, publish_dns +from .ddns_dns import DnsProviderError, DnsReconciliationError, 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 +from .event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED LOGGER = logging.getLogger(__name__) @@ -121,36 +122,75 @@ class DdnsService: myipv6: str | None, ip6addr: str | None, ) -> UpdateOutcome: + started_at = time.perf_counter() + is_success = False + is_dns_failure = False + is_reconcile_failure = False + is_bad_ip = False 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 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.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) + try: + 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 DnsReconciliationError as exc: + is_reconcile_failure = True + LOGGER.warning( + "DNS reconciliation 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_RECONCILE_FAILED, + f"{type(exc).__name__}: {exc}", + source_ip, + ) + raise + except DnsProviderError as exc: + is_dns_failure = True + 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.commit_update_result(planned) + is_success = True + self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "ok", source_ip) + else: + result = planned + is_success = True + self.store.log_update_rollup(hostname, result.ipv4, result.ipv6, UNCHANGED, "ok", source_ip) + return UpdateOutcome(result=result, source_ip=source_ip) + except BadIpError: + is_bad_ip = True + raise + finally: + elapsed_ms = (time.perf_counter() - started_at) * 1000 + self.store.record_update_metrics( + elapsed_ms, + is_success=is_success, + is_dns_failure=is_dns_failure, + is_reconcile_failure=is_reconcile_failure, + is_bad_ip=is_bad_ip, + ) def fritz_update_url(self, account: dict[str, str]) -> str: base = self.settings.public_base_url.rstrip("/") @@ -275,14 +315,21 @@ def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = suffix = settings.hostname_suffix.strip().lower().strip(".") if expand_suffix and suffix and "." not in hostname: hostname = f"{hostname}.{suffix}" - label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?" - if not re.fullmatch(rf"{label}(\.{label})+", hostname): + if not _valid_domain_name(hostname): return "" return hostname def normalize_domain(value: str) -> str: - return normalize_hostname(value, DdnsSettings(), expand_suffix=False) + domain = value.strip().lower().rstrip(".") + if not _valid_domain_name(domain): + return "" + return domain + + +def _valid_domain_name(value: str) -> bool: + label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?" + return bool(re.fullmatch(rf"{label}(\.{label})+", value)) def dns_txt_contains(name: str, expected: str) -> bool: diff --git a/router_dyndns/dns/__init__.py b/router_dyndns/dns/__init__.py index 72910b1..7caac02 100644 --- a/router_dyndns/dns/__init__.py +++ b/router_dyndns/dns/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .base import ( DnsConfigurationError, DnsProviderError, + DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns, @@ -14,6 +15,7 @@ from .rfc2136 import delete_rfc2136_records, publish_rfc2136 __all__ = [ "DnsConfigurationError", "DnsProviderError", + "DnsReconciliationError", "delete_cloudflare_records", "delete_dns_records", "delete_rfc2136_records", diff --git a/router_dyndns/dns/base.py b/router_dyndns/dns/base.py index 2809892..50a6865 100644 --- a/router_dyndns/dns/base.py +++ b/router_dyndns/dns/base.py @@ -9,6 +9,10 @@ class DnsProviderError(RuntimeError): """Raised for expected DNS provider publish/delete failures.""" +class DnsReconciliationError(DnsProviderError): + """Raised when provider read-back does not match the requested DNS state.""" + + class DnsConfigurationError(RuntimeError): """Raised when DNS provider settings are invalid at startup.""" diff --git a/router_dyndns/dns/cloudflare.py b/router_dyndns/dns/cloudflare.py index d090de7..88756ec 100644 --- a/router_dyndns/dns/cloudflare.py +++ b/router_dyndns/dns/cloudflare.py @@ -6,7 +6,7 @@ import urllib.parse import urllib.request from ..ddns_models import DdnsSettings, UpdateResult -from .base import DnsProviderError +from .base import DnsProviderError, DnsReconciliationError def publish_cloudflare(result: UpdateResult, settings: DdnsSettings) -> None: @@ -45,6 +45,7 @@ def _cloudflare_upsert_record(settings: DdnsSettings, hostname: str, record_type _cloudflare_request(settings, f"/dns_records/{record['id']}", method="PATCH", payload=payload) else: _cloudflare_request(settings, "/dns_records", method="POST", payload=payload) + _cloudflare_reconcile_record(settings, hostname, record_type, value) def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, str] | None: @@ -54,6 +55,14 @@ def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: return records[0] if records else None +def _cloudflare_reconcile_record(settings: DdnsSettings, hostname: str, record_type: str, value: str) -> None: + record = _cloudflare_find_record(settings, hostname, record_type) + if not record: + raise DnsReconciliationError(f"Cloudflare {record_type} record was not visible after publish") + if record.get("content") != value: + raise DnsReconciliationError(f"Cloudflare {record_type} record did not match requested content after publish") + + def _cloudflare_request( settings: DdnsSettings, path: str, diff --git a/router_dyndns/event_statuses.py b/router_dyndns/event_statuses.py index f36579a..e880e30 100644 --- a/router_dyndns/event_statuses.py +++ b/router_dyndns/event_statuses.py @@ -2,6 +2,7 @@ from __future__ import annotations DNS_DELETE_FAILED = "dns_delete_failed" DNS_FAILED = "dns_failed" +DNS_RECONCILE_FAILED = "dns_reconcile_failed" UPDATED = "updated" UNCHANGED = "unchanged" diff --git a/router_dyndns/routers/admin.py b/router_dyndns/routers/admin.py index a0ff18b..9e8a7c5 100644 --- a/router_dyndns/routers/admin.py +++ b/router_dyndns/routers/admin.py @@ -19,6 +19,7 @@ from ..web.templates import _copy_row from ..web.templates_admin import _admin_secret_panel, _render_admin_page ADMIN_TABLE_LIMIT = 100 +ADMIN_JSON_OFFSET_CAP = 10_000 def make_router(admin_path: str = "/admin") -> APIRouter: @@ -42,6 +43,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter: 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)), + store.get_service_metrics(), { "accounts": store.count_accounts(), "domain_challenges": store.count_domain_challenges(), @@ -131,7 +133,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter: "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 + notice = _admin_secret_panel("Update URL changed", [_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( @@ -143,7 +145,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter: "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 + notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", 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( @@ -157,10 +159,10 @@ def make_router(admin_path: str = "/admin") -> APIRouter: ) if rotated: notice = _admin_secret_panel( - "Router password rotated", + "Router password changed", [ _copy_row("Domainnamen:", str(rotated["hostname"]), "Public"), - _copy_row("One-box update URL:", service.credentialed_update_url(rotated), "Secret"), + _copy_row("Full update URL:", service.credentialed_update_url(rotated), "Secret"), _copy_row("Benutzername (optional):", str(rotated["username"]), "Public"), _copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"), ], @@ -187,7 +189,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter: settings.cleanup_event_history_hours, ) notice = _admin_secret_panel( - "Cleanup completed", + "Cleanup done", [ _copy_row("Domain claims removed", str(result["domain_challenges"])), _copy_row("Unused hostnames removed", str(result["unused_accounts"])), @@ -202,7 +204,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter: store: StoreDep, _: None = Depends(authenticate_admin), limit: int = Query(100, ge=1, le=500), - offset: int = Query(0, ge=0), + offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP), ) -> dict[str, object]: return { "items": store.list_records_page(limit=limit, offset=offset), @@ -216,12 +218,16 @@ def make_router(admin_path: str = "/admin") -> APIRouter: store: StoreDep, _: None = Depends(authenticate_admin), limit: int = Query(100, ge=1, le=500), - offset: int = Query(0, ge=0), + offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP), + before: str | None = Query(None, max_length=64), ) -> dict[str, object]: + items = store.list_update_events_before(limit=limit, before=before) if before else store.list_update_events_page(limit=limit, offset=offset) + next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None return { - "items": store.list_update_events_page(limit=limit, offset=offset), + "items": items, "limit": limit, "offset": offset, + "next_before": next_before, "total": store.count_update_events(), } diff --git a/router_dyndns/routers/api.py b/router_dyndns/routers/api.py index 45d83e8..6eb851e 100644 --- a/router_dyndns/routers/api.py +++ b/router_dyndns/routers/api.py @@ -22,6 +22,7 @@ from ..schemas import ( _credentials_response, _managed_record, ) +from .path_params import ManagementSlugPath, UpdateSlugPath router = APIRouter(prefix="/api/v1") @@ -30,7 +31,7 @@ router = APIRouter(prefix="/api/v1") "/hostnames/magic", response_model=HostnameCredentials, status_code=status.HTTP_201_CREATED, - summary="Generate a provider-owned DynDNS hostname", + summary="Create a KarlDNS hostname", tags=["hostnames"], responses={500: {"model": ErrorResponse}}, ) @@ -42,12 +43,12 @@ async def create_magic_hostname(payload: MagicHostnameRequest, service: ServiceD @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.", + summary="Read a hostname by private dashboard link", + description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.", tags=["management"], responses={404: {"model": ErrorResponse}}, ) -async def read_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> ManagedRecord: +async def read_managed_hostname(management_slug: ManagementSlugPath, 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") @@ -57,12 +58,12 @@ async def read_managed_hostname(management_slug: str, store: StoreDep, service: @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.", + summary="Delete a hostname by private dashboard link", + description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.", tags=["management"], responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, ) -async def delete_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> Response: +async def delete_managed_hostname(management_slug: ManagementSlugPath, 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") @@ -70,7 +71,7 @@ async def delete_managed_hostname(management_slug: str, store: StoreDep, service 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 + raise HTTPException(status_code=500, detail="DNS delete failed. Hostname was not deleted.") from exc return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -116,7 +117,7 @@ async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> Ve return VerifyDomainResponse( domain=domain, verified=True, - message="domain verified", + message="Domain verified", claim_secret=payload.claim_secret, ) @@ -125,7 +126,7 @@ async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> Ve "/hostnames/custom", response_model=HostnameCredentials, status_code=status.HTTP_201_CREATED, - summary="Generate credentials for a verified custom hostname", + summary="Create a hostname for a verified domain", tags=["hostnames"], responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}}, ) @@ -147,7 +148,7 @@ async def create_custom_hostname(payload: CustomHostnameRequest, service: Servic responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, ) async def update_hostname( - update_slug: str, + update_slug: UpdateSlugPath, request: Request, store: StoreDep, service: ServiceDep, @@ -174,6 +175,6 @@ async def update_hostname( 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 + raise HTTPException(status_code=500, detail="DNS update 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 index 58571ae..baf5091 100644 --- a/router_dyndns/routers/assets.py +++ b/router_dyndns/routers/assets.py @@ -5,13 +5,13 @@ from pathlib import Path from fastapi import APIRouter, Response from fastapi.responses import FileResponse, RedirectResponse -from ..web.styles import APP_CSS +from ..web.styles import ADMIN_CSS, BASE_CSS ASSET_DIR = Path(__file__).parents[1] / "assets" router = APIRouter(include_in_schema=False) -ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"} +ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} -APP_JS = """ +THEME_JS = """ (() => { const media = window.matchMedia("(prefers-color-scheme: dark)"); const storageKey = "karldns-theme"; @@ -30,8 +30,9 @@ APP_JS = """ }; 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 nextTheme = theme === "dark" ? "light" : "dark"; + button.dataset.themeNext = nextTheme; + button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`); }); }; const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => { @@ -54,7 +55,10 @@ APP_JS = """ applyTheme(nextTheme); }); })(); +""".strip() + +CLIPBOARD_JS = """ document.addEventListener("click", async (event) => { const toggle = event.target.closest("[data-secret-toggle]"); if (toggle) { @@ -87,7 +91,10 @@ document.addEventListener("click", async (event) => { }, 1200); } catch (_) {} }); +""".strip() + +ADMIN_TABLES_JS = """ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => { const table = tableShell.querySelector("table"); const tbody = tableShell.querySelector("tbody"); @@ -176,10 +183,22 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => { """.strip() +def _minify_css(css: str) -> str: + return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}") + + +MINIFIED_BASE_CSS = _minify_css(BASE_CSS) +MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS) + + @router.get("/favicon.ico") def favicon() -> FileResponse: return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS) +@router.get("/bootstrap-subset.css") +def bootstrap_subset_css() -> FileResponse: + return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", 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) @@ -196,6 +215,14 @@ def apple_touch_icon_png() -> FileResponse: def logo_png() -> FileResponse: return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) +@router.get("/logo.webp") +def logo_webp() -> FileResponse: + return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS) + +@router.get("/logo-nav.webp") +def logo_nav_webp() -> FileResponse: + return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", 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) @@ -204,6 +231,14 @@ def logo_192_png() -> FileResponse: def logo_512_png() -> FileResponse: return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) +@router.get("/social-preview.png") +def social_preview_png() -> FileResponse: + return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS) + +@router.get("/social-preview.webp") +def social_preview_webp() -> FileResponse: + return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS) + @router.get("/site.webmanifest") def site_webmanifest() -> Response: return Response( @@ -211,7 +246,7 @@ def site_webmanifest() -> Response: { "name": "KarlDNS", "short_name": "KarlDNS", - "description": "Accountless Dynamic DNS for routers and home labs.", + "description": "Private update links for routers.", "start_url": "/", "scope": "/", "display": "standalone", @@ -227,14 +262,29 @@ def site_webmanifest() -> Response: 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("/theme.js") +def theme_js() -> Response: + return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) + + +@router.get("/clipboard.js") +def clipboard_js() -> Response: + return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS) + + +@router.get("/admin-tables.js") +def admin_tables_js() -> Response: + return Response(ADMIN_TABLES_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) + return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS) + + +@router.get("/admin.css") +def admin_css() -> Response: + return Response(MINIFIED_ADMIN_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS) @router.get("/install.sh") diff --git a/router_dyndns/routers/management.py b/router_dyndns/routers/management.py index 8471271..ef239a6 100644 --- a/router_dyndns/routers/management.py +++ b/router_dyndns/routers/management.py @@ -26,6 +26,7 @@ from .management_shared import ( locked_management_response, render_management_page_async, ) +from .path_params import ManagementSlugPath router = APIRouter() router.include_router(management_passwords.router) @@ -33,24 +34,24 @@ 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: +def manage_magic(management_slug: ManagementSlugPath, 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): + if not has_management_access(request, settings, 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, + management_slug: ManagementSlugPath, 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): + if not has_management_access(request, settings, 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")) @@ -75,7 +76,7 @@ async def rotate_magic_credentials( management_slug, rotated, dashboard_csrf_token(management_slug, rotated), - message="Update-URL rotated.", + message="Update URL changed. Update your router now.", ) if action == "password": rotated = await run_in_threadpool(store.rotate_password, hostname) @@ -97,16 +98,16 @@ async def rotate_magic_credentials( management_slug, rotated, dashboard_csrf_token(management_slug, rotated), - message="Router credentials rotated. Copy the new password now.", + message="Router password changed. Copy it 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: +async def delete_magic(management_slug: ManagementSlugPath, 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): + if not has_management_access(request, settings, 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")) @@ -114,7 +115,7 @@ async def delete_magic(management_slug: str, request: Request, settings: Setting 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) + return PlainTextResponse("DNS delete failed. Hostname was not deleted.", status_code=500) await run_in_threadpool( store.log_update_event, hostname, @@ -126,7 +127,7 @@ async def delete_magic(management_slug: str, request: Request, settings: Setting ) return HTMLResponse( _page( - "DynDNS deleted", + "Hostname deleted", trusted( """
diff --git a/router_dyndns/routers/management_domains.py b/router_dyndns/routers/management_domains.py index 4eaad14..2020b43 100644 --- a/router_dyndns/routers/management_domains.py +++ b/router_dyndns/routers/management_domains.py @@ -23,12 +23,13 @@ from .management_shared import ( locked_management_response, render_management_page_async, ) +from .path_params import ClaimSecretPath, ManagementSlugPath 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: +async def manage_domain_claim(claim_secret: ClaimSecretPath, 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") @@ -37,14 +38,14 @@ async def manage_domain_claim(claim_secret: str, settings: SettingsDep, store: S @router.post("/m/{management_slug}/domains/request", response_class=HTMLResponse, include_in_schema=False) async def request_dashboard_domain( - management_slug: str, + management_slug: ManagementSlugPath, 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): + if not has_management_access(request, settings, 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")) @@ -71,28 +72,28 @@ async def request_dashboard_domain( management_slug, account, dashboard_csrf_token(management_slug, account), - message="Domain claim created. Add the TXT record, then verify it.", + message="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, + management_slug: ManagementSlugPath, 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): + if not has_management_access(request, settings, 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." + message = "Domain verified. You can create a hostname now." if found else "TXT record not found yet. Try again in a few minutes." await run_in_threadpool( store.log_update_event, str(account["hostname"]), @@ -115,14 +116,14 @@ async def verify_dashboard_domain( @router.post("/m/{management_slug}/domains/hostnames", response_class=HTMLResponse, include_in_schema=False) async def create_dashboard_custom_hostname( - management_slug: str, + management_slug: ManagementSlugPath, 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): + if not has_management_access(request, settings, 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")) @@ -148,6 +149,6 @@ async def create_dashboard_custom_hostname( management_slug, account, dashboard_csrf_token(management_slug, account), - message="Custom hostname created. Copy these router settings now.", + message="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 index 467da05..09ace36 100644 --- a/router_dyndns/routers/management_passwords.py +++ b/router_dyndns/routers/management_passwords.py @@ -18,12 +18,13 @@ from .management_shared import ( set_unlock_cookie, unlock_cookie_seconds, ) +from .path_params import ManagementSlugPath 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: +async def unlock_magic(management_slug: ManagementSlugPath, 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) @@ -34,7 +35,7 @@ async def unlock_magic(management_slug: str, request: Request, settings: Setting 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."), + _render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect password."), status_code=401, ) response = RedirectResponse(f"/m/{management_slug}", status_code=303) @@ -50,14 +51,14 @@ async def unlock_magic(management_slug: str, request: Request, settings: Setting @router.post("/m/{management_slug}/password", include_in_schema=False) async def set_dashboard_password( - management_slug: str, + management_slug: ManagementSlugPath, 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): + if not has_management_access(request, settings, 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")) @@ -69,7 +70,7 @@ async def set_dashboard_password( 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.") + return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.") password = first_form_value(form, "dashboard_password") confirm = first_form_value(form, "dashboard_password_confirm") if len(password) < 8: @@ -79,7 +80,7 @@ async def set_dashboard_password( management_slug, account, dashboard_csrf_token(management_slug, account), - message="Dashboard password must be at least 8 characters.", + message="Password must be at least 8 characters.", ) return HTMLResponse(page, status_code=400) if password != confirm: @@ -89,7 +90,7 @@ async def set_dashboard_password( management_slug, account, dashboard_csrf_token(management_slug, account), - message="Dashboard password confirmation did not match.", + message="The passwords do not match.", ) return HTMLResponse(page, status_code=400) had_password = bool(account.get("dashboard_password_hash")) @@ -111,14 +112,14 @@ async def set_dashboard_password( @router.post("/m/{management_slug}/password/remove", include_in_schema=False) async def remove_dashboard_password( - management_slug: str, + management_slug: ManagementSlugPath, 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): + if not has_management_access(request, settings, 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")) @@ -130,7 +131,7 @@ async def remove_dashboard_password( 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.") + return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.") await run_in_threadpool(store.clear_dashboard_password, hostname) await run_in_threadpool( store.log_update_event, diff --git a/router_dyndns/routers/management_shared.py b/router_dyndns/routers/management_shared.py index 2999d35..b6f0e0d 100644 --- a/router_dyndns/routers/management_shared.py +++ b/router_dyndns/routers/management_shared.py @@ -7,6 +7,7 @@ from starlette.concurrency import run_in_threadpool from ..ddns_models import DdnsSettings from ..ddns_security import ( DASHBOARD_UNLOCK_COOKIE, + dashboard_cookie_signing_secret, dashboard_csrf_token, sign_dashboard_unlock_cookie, verify_dashboard_unlock_cookie, @@ -30,7 +31,7 @@ async def get_active_management_account_async(store: DdnsStore, management_slug: return await run_in_threadpool(get_active_management_account, store, management_slug) -def has_management_access(request: Request, management_slug: str, account: ManagementAccount) -> bool: +def has_management_access(request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount) -> bool: password_hash = account.get("dashboard_password_hash") if not password_hash: return True @@ -38,6 +39,7 @@ def has_management_access(request: Request, management_slug: str, account: Manag str(account["hostname"]), management_slug, str(password_hash), + dashboard_cookie_signing_secret(settings), request.cookies.get(DASHBOARD_UNLOCK_COOKIE), ) @@ -100,11 +102,18 @@ def set_unlock_cookie( max_age_seconds: int, ) -> None: password_hash = account.get("dashboard_password_hash") - if not password_hash: + signing_secret = dashboard_cookie_signing_secret(settings) + if not password_hash or not signing_secret: return response.set_cookie( DASHBOARD_UNLOCK_COOKIE, - sign_dashboard_unlock_cookie(str(account["hostname"]), management_slug, str(password_hash), max_age_seconds), + sign_dashboard_unlock_cookie( + str(account["hostname"]), + management_slug, + str(password_hash), + signing_secret, + max_age_seconds, + ), max_age=max_age_seconds, path=f"/m/{management_slug}", httponly=True, diff --git a/router_dyndns/routers/path_params.py b/router_dyndns/routers/path_params.py new file mode 100644 index 0000000..0b22131 --- /dev/null +++ b/router_dyndns/routers/path_params.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Path + +TOKEN_URLSAFE_PATTERN = r"^[A-Za-z0-9_-]+$" +TOKEN_MIN_LENGTH = 32 +TOKEN_MAX_LENGTH = 128 + +UpdateSlugPath = Annotated[ + str, + Path( + min_length=TOKEN_MIN_LENGTH, + max_length=TOKEN_MAX_LENGTH, + pattern=TOKEN_URLSAFE_PATTERN, + description="Private update URL value.", + ), +] +ManagementSlugPath = Annotated[ + str, + Path( + min_length=TOKEN_MIN_LENGTH, + max_length=TOKEN_MAX_LENGTH, + pattern=TOKEN_URLSAFE_PATTERN, + description="Private dashboard URL value.", + ), +] +ClaimSecretPath = Annotated[ + str, + Path( + min_length=TOKEN_MIN_LENGTH, + max_length=TOKEN_MAX_LENGTH, + pattern=TOKEN_URLSAFE_PATTERN, + description="Private domain claim value.", + ), +] diff --git a/router_dyndns/routers/public.py b/router_dyndns/routers/public.py index e576953..2f72c11 100644 --- a/router_dyndns/routers/public.py +++ b/router_dyndns/routers/public.py @@ -3,7 +3,7 @@ from __future__ import annotations import sqlite3 from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response from starlette.concurrency import run_in_threadpool from ..ddns_security import client_ip @@ -52,9 +52,9 @@ async def verify_domain(request: Request, settings: SettingsDep, service: Servic 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." + message = "TXT record not found yet. Try again in 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) + return _render_public_home(service, settings, None, "Domain verified. You can create a hostname now.", challenge) @router.post("/accounts", response_class=HTMLResponse, include_in_schema=False) @@ -85,5 +85,47 @@ def visible_ip(request: Request, settings: SettingsDep) -> str: @router.get("/privacy", response_class=HTMLResponse, include_in_schema=False) -def privacy() -> str: - return _render_privacy_page() +def privacy(settings: SettingsDep) -> str: + return _render_privacy_page(settings) + + +@router.get("/robots.txt", response_class=PlainTextResponse, include_in_schema=False) +def robots_txt(settings: SettingsDep) -> str: + base_url = settings.public_base_url.rstrip("/") + admin_path = settings.admin_path.rstrip("/") + return "\n".join( + [ + "User-agent: *", + f"Disallow: {admin_path}/", + "Disallow: /m/", + "Disallow: /d/", + "Disallow: /u/", + "Disallow: /nic/", + "Disallow: /api/", + "Disallow: /docs", + "Disallow: /redoc", + "Disallow: /openapi.json", + f"Sitemap: {base_url}/sitemap.xml", + "", + ] + ) + + +@router.get("/sitemap.xml", include_in_schema=False) +def sitemap_xml(settings: SettingsDep) -> Response: + base_url = settings.public_base_url.rstrip("/") + body = f""" + + + {base_url}/ + weekly + 1.0 + + + {base_url}/privacy + monthly + 0.3 + + +""" + return Response(body, media_type="application/xml") diff --git a/router_dyndns/routers/updates.py b/router_dyndns/routers/updates.py index 0c13d60..d89e659 100644 --- a/router_dyndns/routers/updates.py +++ b/router_dyndns/routers/updates.py @@ -6,13 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import PlainTextResponse from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update +from .path_params import UpdateSlugPath router = APIRouter() @router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None) def slug_update( - update_slug: str, + update_slug: UpdateSlugPath, request: Request, store: StoreDep, service: ServiceDep, diff --git a/router_dyndns/schemas.py b/router_dyndns/schemas.py index aa0b92e..53e1ec6 100644 --- a/router_dyndns/schemas.py +++ b/router_dyndns/schemas.py @@ -17,17 +17,17 @@ 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.") + update_url: str = Field(description="Secret update URL for router DynDNS updates.") credentialed_update_url: str = Field( - description="Secret update URL with router credentials embedded in the query string." + description="Secret full update URL with router username and password included." ) - management_url: str = Field(description="Secret bearer management URL for reading or deleting this hostname.") + management_url: str = Field(description="Secret dashboard 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.") + update_url: str = Field(description="Secret update URL for router DynDNS updates.") ipv4: str | None = None ipv6: str | None = None updated_at: str | None = None @@ -43,7 +43,7 @@ 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.") + claim_secret: str = Field(description="Secret value used to return to and verify this domain claim.") class VerifyDomainRequest(BaseModel): @@ -57,7 +57,7 @@ class VerifyDomainResponse(BaseModel): domain: str verified: bool message: str - claim_secret: str = Field(description="Secret bearer token for continuing this domain claim flow.") + claim_secret: str = Field(description="Secret value used to continue this domain claim flow.") class CustomHostnameRequest(BaseModel): diff --git a/router_dyndns/store/__init__.py b/router_dyndns/store/__init__.py index 0d2c3e5..a616897 100644 --- a/router_dyndns/store/__init__.py +++ b/router_dyndns/store/__init__.py @@ -8,13 +8,14 @@ from time import monotonic from .accounts import AccountsMixin from .domains import DomainsMixin +from .metrics import MetricsMixin 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): +class DdnsStore(AccountsMixin, DomainsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin): def __init__(self, database_path: Path) -> None: self.database_path = database_path self._connections = threading.local() diff --git a/router_dyndns/store/accounts.py b/router_dyndns/store/accounts.py index e54d683..dafe471 100644 --- a/router_dyndns/store/accounts.py +++ b/router_dyndns/store/accounts.py @@ -2,7 +2,6 @@ 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 @@ -36,7 +35,6 @@ class AccountsMixin: 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, @@ -57,7 +55,6 @@ class AccountsMixin: 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, @@ -72,7 +69,6 @@ class AccountsMixin: 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 @@ -84,7 +80,6 @@ class AccountsMixin: 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, @@ -101,7 +96,6 @@ class AccountsMixin: 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 @@ -162,7 +156,6 @@ class AccountsMixin: 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 diff --git a/router_dyndns/store/domains.py b/router_dyndns/store/domains.py index 538a123..d44673b 100644 --- a/router_dyndns/store/domains.py +++ b/router_dyndns/store/domains.py @@ -47,7 +47,6 @@ class DomainsMixin: 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: @@ -76,7 +75,6 @@ class DomainsMixin: 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: @@ -90,7 +88,6 @@ class DomainsMixin: 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),), @@ -103,7 +100,6 @@ class DomainsMixin: 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 diff --git a/router_dyndns/store/metrics.py b/router_dyndns/store/metrics.py new file mode 100644 index 0000000..8a00994 --- /dev/null +++ b/router_dyndns/store/metrics.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from datetime import UTC, datetime + + +class MetricsMixin: + def increment_metric(self, name: str, amount: float = 1.0) -> None: + now = datetime.now(UTC).isoformat() + with self.connect() as conn: + conn.execute( + """ + INSERT INTO service_metrics(name, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + value=value + excluded.value, + updated_at=excluded.updated_at + """, + (name, amount, now), + ) + + def set_metric(self, name: str, value: float) -> None: + now = datetime.now(UTC).isoformat() + with self.connect() as conn: + conn.execute( + """ + INSERT INTO service_metrics(name, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + value=excluded.value, + updated_at=excluded.updated_at + """, + (name, value, now), + ) + + def get_service_metrics(self) -> dict[str, float]: + with self.connect() as conn: + rows = conn.execute("SELECT name, value FROM service_metrics").fetchall() + return {str(row["name"]): float(row["value"]) for row in rows} + + def record_update_metrics( + self, + latency_ms: float, + *, + is_success: bool = False, + is_dns_failure: bool = False, + is_reconcile_failure: bool = False, + is_bad_ip: bool = False, + ) -> None: + increments = { + "update_requests_total": 1, + "update_latency_ms_total": latency_ms, + "update_latency_samples_total": 1, + } + if is_success: + increments["update_success_total"] = 1 + if is_dns_failure: + increments["dns_failures_total"] = 1 + if is_reconcile_failure: + increments["dns_reconcile_failures_total"] = 1 + if is_bad_ip: + increments["bad_ip_total"] = 1 + + now = datetime.now(UTC).isoformat() + with self.connect() as conn: + conn.executemany( + """ + INSERT INTO service_metrics(name, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + value=value + excluded.value, + updated_at=excluded.updated_at + """, + [(name, value, now) for name, value in increments.items()], + ) + conn.execute( + """ + INSERT INTO service_metrics(name, value, updated_at) + VALUES ('last_update_latency_ms', ?, ?) + ON CONFLICT(name) DO UPDATE SET + value=excluded.value, + updated_at=excluded.updated_at + """, + (latency_ms, now), + ) diff --git a/router_dyndns/store/rate_limit.py b/router_dyndns/store/rate_limit.py index e7937b6..d7038e7 100644 --- a/router_dyndns/store/rate_limit.py +++ b/router_dyndns/store/rate_limit.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sqlite3 import time from datetime import UTC, datetime, timedelta @@ -12,7 +11,6 @@ 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 diff --git a/router_dyndns/store/records.py b/router_dyndns/store/records.py index ba8fe89..fda6041 100644 --- a/router_dyndns/store/records.py +++ b/router_dyndns/store/records.py @@ -18,7 +18,6 @@ class RecordsEventsMixin: ) -> 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 @@ -70,7 +69,6 @@ class RecordsEventsMixin: 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 @@ -91,7 +89,6 @@ class RecordsEventsMixin: 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 @@ -154,28 +151,45 @@ class RecordsEventsMixin: 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( + rows = conn.execute( """ - SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count - FROM update_events + SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, count + FROM ( + SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count + FROM update_events + UNION ALL + SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count + FROM update_event_rollups + ) ORDER BY created_at DESC LIMIT ? OFFSET ? """, - (source_limit, 0), + (limit, offset), ).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 ? + return [dict(row) for row in rows] + + def list_update_events_before(self, limit: int = 100, before: str | None = None) -> list[UpdateEventRow]: + where_clause = "WHERE created_at < ?" if before else "" + params: tuple[object, ...] = (before, limit) if before else (limit,) + with self.connect() as conn: + rows = conn.execute( + f""" + SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, count + FROM ( + SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count + FROM update_events + UNION ALL + SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count + FROM update_event_rollups + ) + {where_clause} + ORDER BY created_at DESC + LIMIT ? """, - (source_limit, 0), + params, ).fetchall() - return _merge_event_rows(event_rows, rollup_rows, limit, offset) + return [dict(row) for row in rows] def count_update_events(self) -> int: with self.connect() as conn: @@ -185,7 +199,6 @@ class RecordsEventsMixin: 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 diff --git a/router_dyndns/store/scheduler.py b/router_dyndns/store/scheduler.py index 7491f6d..c742c56 100644 --- a/router_dyndns/store/scheduler.py +++ b/router_dyndns/store/scheduler.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sqlite3 from datetime import UTC, datetime, timedelta from .types import ScheduledTaskRow @@ -87,7 +86,6 @@ class SchedulerMixin: 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, diff --git a/router_dyndns/store/schema.py b/router_dyndns/store/schema.py index 78f6db3..3a36ade 100644 --- a/router_dyndns/store/schema.py +++ b/router_dyndns/store/schema.py @@ -17,6 +17,7 @@ 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) + conn.row_factory = sqlite3.Row configure_connection(conn) return conn @@ -129,6 +130,15 @@ def initialize_schema(conn: sqlite3.Connection) -> None: """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS service_metrics ( + name TEXT PRIMARY KEY, + value REAL NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS accounts ( diff --git a/router_dyndns/web/security_headers.py b/router_dyndns/web/security_headers.py index f9dba19..43b29ab 100644 --- a/router_dyndns/web/security_headers.py +++ b/router_dyndns/web/security_headers.py @@ -1,5 +1,8 @@ from __future__ import annotations +import logging +import time + from fastapi import FastAPI, Request from fastapi.responses import PlainTextResponse from starlette.concurrency import run_in_threadpool @@ -9,6 +12,8 @@ from ..ddns_models import DdnsSettings from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket from ..ddns_store import DdnsStore +ACCESS_LOGGER = logging.getLogger("router_dyndns.access") + class RequestBodyTooLargeError(Exception): pass @@ -46,6 +51,7 @@ class RequestBodyLimitMiddleware: def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None: @app.middleware("http") async def security_middleware(request: Request, call_next): + started_at = time.perf_counter() try: content_length = int(request.headers.get("content-length", "0") or "0") except ValueError: @@ -62,12 +68,15 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn 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: + await run_in_threadpool(store.increment_metric, "rate_limit_hits_total") 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" + if is_noindex_response(request): + response.headers["X-Robots-Tag"] = "noindex, nofollow" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "same-origin" @@ -75,6 +84,15 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn 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" + if settings.redacted_access_log: + elapsed_ms = (time.perf_counter() - started_at) * 1000 + ACCESS_LOGGER.info( + "%s %s status=%s duration_ms=%.2f", + request.method, + redacted_access_path(request.url.path), + response.status_code, + elapsed_ms, + ) return response @@ -96,6 +114,17 @@ def is_secret_response(request: Request) -> bool: return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update" +def is_noindex_response(request: Request) -> bool: + path = request.url.path + if request.method != "GET": + return True + if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}: + return True + if path.startswith(("/api/", "/m/", "/d/", "/u/", "/nic/")): + return True + return is_admin_path(path, request.app.state.settings) + + def content_security_policy(path: str) -> str: if path in {"/docs", "/redoc", "/openapi.json"}: return ( @@ -108,13 +137,30 @@ def content_security_policy(path: str) -> str: "form-action 'self'; " "frame-ancestors 'none'" ) + script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; " return ( "default-src 'self'; " - "script-src 'self'; " - "style-src 'self' https://cdn.jsdelivr.net; " + f"{script_src}" + "style-src 'self'; " "img-src 'self' data:; " - "font-src 'self' https://cdn.jsdelivr.net; " + "font-src 'self'; " "connect-src 'self'; " "form-action 'self'; " "frame-ancestors 'none'" ) + + +def redacted_access_path(path: str) -> str: + secret_prefixes = { + "/u": "/u/", + "/m": "/m/", + "/d": "/d/", + "/api/v1/updates": "/api/v1/updates/", + "/api/v1/management": "/api/v1/management/", + } + for prefix, replacement in secret_prefixes.items(): + if path == prefix or path.startswith(f"{prefix}/"): + return replacement + if path == "/nic/update": + return "/nic/update?" + return path diff --git a/router_dyndns/web/styles.py b/router_dyndns/web/styles.py index 98e7874..4103f32 100644 --- a/router_dyndns/web/styles.py +++ b/router_dyndns/web/styles.py @@ -1,6 +1,6 @@ from __future__ import annotations -APP_CSS = """ +BASE_CSS = """ :root { color-scheme: light; --bs-primary: #000000; @@ -70,6 +70,10 @@ body { font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +code { + color: var(--rp-ink); +} + .justify-items-center { justify-items: center; } @@ -158,7 +162,29 @@ body { } .theme-toggle { - min-width: 58px; + align-items: center; + aspect-ratio: 1; + display: inline-flex; + justify-content: center; + min-height: 40px; + min-width: 40px; + padding: 0; +} + +.theme-icon { + display: block; +} + +.theme-icon-sun { + display: none; +} + +[data-bs-theme="dark"] .theme-icon-moon { + display: none; +} + +[data-bs-theme="dark"] .theme-icon-sun { + display: block; } .hero-band { @@ -367,9 +393,9 @@ input:focus, } .btn-sm { - min-width: 34px; - min-height: 34px; - padding-inline: 14px; + min-width: 40px; + min-height: 40px; + padding-inline: 16px; border-radius: 9999px; } @@ -424,9 +450,13 @@ select:focus-visible { .btn-outline-danger { --bs-btn-color: var(--rp-danger); --bs-btn-border-color: var(--rp-line); + --bs-btn-bg: var(--rp-field); --bs-btn-hover-color: #ffffff; --bs-btn-hover-bg: var(--rp-danger); --bs-btn-hover-border-color: var(--rp-danger); + --bs-btn-active-color: #ffffff; + --bs-btn-active-bg: var(--rp-danger); + --bs-btn-active-border-color: var(--rp-danger); } .text-primary { @@ -461,6 +491,10 @@ select:focus-visible { border-radius: 12px; } +.alert code { + color: var(--rp-ink); +} + .copy-field { min-width: 0; max-width: 100%; @@ -538,53 +572,6 @@ select:focus-visible { 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); @@ -594,64 +581,6 @@ select:focus-visible { 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 { @@ -750,6 +679,146 @@ th { border-radius: 0 0 14px 14px; } + h1 { + font-size: 28px; + line-height: 1.15; + } + + h2 { + font-size: 24px; + } +} +""".strip() + + +ADMIN_CSS = """ +.metric-card { + display: grid; + gap: 6px; + padding: 16px; + border: 1px solid var(--rp-line); + border-radius: 16px; + background: var(--rp-surface); +} + +.metric-card span { + color: var(--rp-muted); + font-size: 13px; + font-weight: 500; +} + +.metric-card strong { + color: var(--rp-ink); + font-size: 20px; + font-weight: 600; +} + +.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); +} + +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) { .admin-table-shell .form-control { max-width: none; } @@ -767,14 +836,5 @@ 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 index 5319ebf..4433f96 100644 --- a/router_dyndns/web/templates.py +++ b/router_dyndns/web/templates.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from .html_utils import SafeHtml, attr, escape, join_html, trusted @@ -9,14 +11,22 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm account_html = f'{escape(label)}' if links is None: links = trusted('DocsAPI') - account_html += 'Generate' - theme_toggle = '' + theme_toggle = """ + + """ return trusted( f"""
"""), + extra_styles=trusted(''), ) def _management_message(message: str | None) -> SafeHtml: @@ -176,22 +177,22 @@ def _management_message(message: str | None) -> SafeHtml: 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" + password_action = "Change password" if password_enabled else "Set password" remove_form = "" if password_enabled: remove_form = f"""
- + - +
""" current_password_field = "" if password_enabled: current_password_field = """
- +
""" @@ -203,7 +204,7 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en

Dashboard password

Password protection

-

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

+

Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.

@@ -249,7 +250,7 @@ def _render_management_unlock_page(

Dashboard locked

{hostname}

-

Enter the dashboard password to manage this hostname.

+

Enter the dashboard password for this hostname.

@@ -263,9 +264,9 @@ def _render_management_unlock_page(
- + @@ -285,15 +286,15 @@ def _management_domain_claim_panel(

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.

+

Use your domain

+

Create a TXT record, verify it here, then create a hostname under that domain.

- +
""" @@ -312,7 +313,7 @@ def _management_domain_claim_panel( 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" + status = "Verified" if challenge.get("verified_at") else "Waiting for TXT" 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( @@ -325,11 +326,11 @@ def _management_domain_claim_panel(

Domain claim

{escape(domain)}

Status: {escape(status)}

-

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

+

This claim belongs to this dashboard. Other dashboards cannot use it.

-
TXT name and value are public DNS records. The claim secret and private claim link are secrets.
+
TXT name and value are public. Claim secret and private claim link are secret.
- {_copy_button("Copy TXT record", txt_bundle)} + {_copy_button("Copy TXT", txt_bundle)}
{_copy_row("TXT name", verification_name, "Public")} @@ -341,7 +342,7 @@ def _management_domain_claim_panel( - + {create_hostname}
@@ -358,10 +359,10 @@ def _management_custom_hostname_form(management_slug: str, claim_secret: str, cs
{csrf_field} -