Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+2
View File
@@ -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
+1
View File
@@ -3,6 +3,7 @@
__pycache__/
.pytest_cache/
.ruff_cache/
.playwright-cli/
*.sqlite3
*.sqlite3-*
*.db
+26 -9
View File
@@ -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/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>
FRITZ!Box replaces `<ipaddr>` and `<ip6addr>` 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. |
+42
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

After

Width:  |  Height:  |  Size: 95 KiB

+6 -3
View File
@@ -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)
+1
View File
@@ -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(--bLine truncated
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+8 -6
View File
@@ -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:
+2
View File
@@ -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),
)
+2 -1
View File
@@ -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",
+2
View File
@@ -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",
+19 -1
View File
@@ -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:
+34 -8
View File
@@ -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:
+81 -34
View File
@@ -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:
+2
View File
@@ -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",
+4
View File
@@ -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."""
+10 -1
View File
@@ -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,
+1
View File
@@ -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"
+14 -8
View File
@@ -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(),
}
+13 -12
View File
@@ -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 "")
+60 -10
View File
@@ -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")
+11 -10
View File
@@ -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(
"""
<main>
+11 -10
View File
@@ -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,
)
+11 -10
View File
@@ -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,
+12 -3
View File
@@ -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,
+37
View File
@@ -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.",
),
]
+47 -5
View File
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>{base_url}/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>{base_url}/privacy</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
"""
return Response(body, media_type="application/xml")
+2 -1
View File
@@ -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,
+6 -6
View File
@@ -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):
+2 -1
View File
@@ -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()
-7
View File
@@ -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
-4
View File
@@ -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
+84
View File
@@ -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),
)
-2
View File
@@ -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
+31 -18
View File
@@ -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
-2
View File
@@ -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,
+10
View File
@@ -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 (
+49 -3
View File
@@ -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/<secret>",
"/m": "/m/<secret>",
"/d": "/d/<secret>",
"/api/v1/updates": "/api/v1/updates/<secret>",
"/api/v1/management": "/api/v1/management/<secret>",
}
for prefix, replacement in secret_prefixes.items():
if path == prefix or path.startswith(f"{prefix}/"):
return replacement
if path == "/nic/update":
return "/nic/update?<redacted>"
return path
+179 -119
View File
@@ -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()
+79 -15
View File
@@ -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'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">Docs</a><a class="nav-link px-0" href="/redoc">API</a>')
account_html += '<a class="btn btn-sm btn-primary rounded-pill" href="/#create">Generate</a>'
theme_toggle = '<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch color theme">Light</button>'
theme_toggle = """
<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
<svg class="theme-icon theme-icon-moon" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 0a6.8 6.8 0 0 0-.8 3.2A7 7 0 0 0 12.2 10a6.8 6.8 0 0 0 3-.7A8 8 0 1 1 6 0Z"/>
</svg>
<svg class="theme-icon theme-icon-sun" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 11.2A3.2 3.2 0 1 0 8 4.8a3.2 3.2 0 0 0 0 6.4Zm0-9.8a.7.7 0 0 1-.7-.7V.2a.7.7 0 0 1 1.4 0v.5a.7.7 0 0 1-.7.7Zm0 14.6a.7.7 0 0 1-.7-.7v-.5a.7.7 0 1 1 1.4 0v.5a.7.7 0 0 1-.7.7ZM1.4 8a.7.7 0 0 1-.7.7H.2a.7.7 0 0 1 0-1.4h.5a.7.7 0 0 1 .7.7Zm14.6 0a.7.7 0 0 1-.7.7h-.5a.7.7 0 1 1 0-1.4h.5a.7.7 0 0 1 .7.7ZM3.3 3.3a.7.7 0 0 1-1 0L2 3a.7.7 0 0 1 1-1l.3.3a.7.7 0 0 1 0 1Zm10.7 10.7a.7.7 0 0 1-1 0l-.3-.3a.7.7 0 1 1 1-1l.3.3a.7.7 0 0 1 0 1Zm-10.7-1a.7.7 0 0 1 0 1l-.3.3a.7.7 0 1 1-1-1l.3-.3a.7.7 0 0 1 1 0ZM14 2a.7.7 0 0 1 0 1l-.3.3a.7.7 0 1 1-1-1L13 2a.7.7 0 0 1 1 0Z"/>
</svg>
</button>
"""
return trusted(
f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo.png" alt="" width="28" height="28">KarlDNS</a>
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
{links}
{account_html}
@@ -71,7 +81,7 @@ def _manual_update_commands(update_url: str) -> str:
[
f"curl -sS '{update_base}?myip=203.0.113.10'",
f"curl -sS '{update_base}?myipv6=2001:db8::10'",
f"curl -sS '{update_base}'",
f"curl -sS '{update_base}' # use request IP",
]
)
@@ -83,10 +93,10 @@ def _manual_update_panel(commands: str) -> SafeHtml:
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box replaces <code>&lt;ipaddr&gt;</code> and <code>&lt;ip6addr&gt;</code>, then sends a GET request to the Update-URL. Scripts and other routers can do the same with curl. If no IP parameter is sent, KarlDNS uses the request source IP.</p>
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
</div>
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
{_copy_button("Copy curl examples", commands)}
{_copy_button("Copy curl", commands)}
</div>
"""
)
@@ -107,7 +117,7 @@ def _fritzbox_fields(
f"Kennwort: {password}",
]
if management_url:
lines.append(f"Private status page: {management_url}")
lines.append(f"Private dashboard: {management_url}")
return "\n".join(lines)
@@ -115,9 +125,9 @@ def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Domainnamen and CNAME target can be shared in DNS.
<strong>Secret:</strong> Update-URL, one-box update URL, optional Kennwort, private dashboard URL, and domain claim links control the hostname.
Secrets are hidden by default on screen; Copy still copies the real value.
<strong>Public:</strong> Domain name and CNAME target can be shared.
<strong>Secret:</strong> Update URLs, router password, dashboard link, and claim links can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
)
@@ -127,7 +137,7 @@ def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the router password and one-box update URL now. They are only shown when generated or rotated and are not recoverable from the dashboard later.
Copy the router password and full update URL now. They are only shown when created or rotated.
</div>
"""
)
@@ -165,9 +175,55 @@ def _site_footer() -> SafeHtml:
)
def _page(title: str, body: SafeHtml) -> str:
def _page(
title: str,
body: SafeHtml,
extra_styles: SafeHtml | None = None,
extra_scripts: SafeHtml | None = None,
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
preload_logo: bool = False,
canonical_url: str | None = None,
image_url: str | None = None,
image_alt: str | None = None,
structured_data: dict[str, object] | None = None,
) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("")
extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or ""
social_image = image_url or ""
social_html = trusted("")
if social_url:
social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title
social_image_html = f"""
<meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
""" if social_image else ""
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(description)}">
<meta property="og:url" content="{attr(social_url)}">
{social_image_html}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
structured_data_html = trusted("")
if structured_data:
structured_data_html = trusted(
'<script type="application/ld+json">'
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
+ "</script>"
)
return f"""
<!doctype html>
<html lang="en">
@@ -175,15 +231,23 @@ def _page(title: str, body: SafeHtml) -> str:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<meta name="description" content="{attr(description)}">
{canonical_html}
{social_html}
<title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<script src="/app.js" defer></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/app.css" rel="stylesheet">
{preload_logo_html}
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=2" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=2" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
</head>
<body>{body}{_site_footer()}</body>
</html>
+124 -68
View File
@@ -11,10 +11,8 @@ from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_cleanup_row,
_domain_claim_row,
_event_row,
_scheduled_task_row,
)
@@ -24,7 +22,7 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
<div class="container">
<p class="eyebrow">Admin action</p>
<h2>{escape(title)}</h2>
<p class="section-copy">Copy this now. Secret values are only shown in this response.</p>
<p class="section-copy">Copy these values now. Secret values are only shown once.</p>
<div class="d-grid gap-2">
{"".join(rows)}
</div>
@@ -40,6 +38,7 @@ def _render_admin_page(
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
service_metrics: dict[str, float],
table_counts: dict[str, int],
table_offsets: dict[str, int],
table_limit: int,
@@ -56,19 +55,13 @@ def _render_admin_page(
domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """
<tr class="empty-row"><td colspan="5" class="empty">No domain claims yet.</td></tr>
"""
cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """
<tr class="empty-row"><td colspan="3" class="empty">No cleanup runs yet.</td></tr>
"""
scheduled_task_rows = "\n".join(_scheduled_task_row(task) for task in scheduled_tasks) or """
<tr class="empty-row"><td colspan="6" class="empty">No scheduled tasks yet.</td></tr>
"""
event_rows = "\n".join(_event_row(event) for event in update_events) or """
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
"""
accounts_count = escape(table_counts.get("accounts", len(accounts)))
domain_count = escape(table_counts.get("domain_challenges", len(domain_challenges)))
cleanup_count = escape(table_counts.get("cleanup_runs", len(cleanup_runs)))
event_count = escape(table_counts.get("update_events", len(update_events)))
metrics_html = _metrics_panel(service_metrics)
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
domain_pager = _server_page_links(
admin_path,
@@ -77,18 +70,18 @@ def _render_admin_page(
table_limit,
table_counts.get("domain_challenges", 0),
)
cleanup_pager = _server_page_links(admin_path, "cleanup_runs_offset", table_offsets.get("cleanup_runs", 0), table_limit, table_counts.get("cleanup_runs", 0))
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator console", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
{_top_nav("Operator", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
<section class="section admin-heading">
<div class="container">
<p class="eyebrow">KarlDNS aktiv</p>
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
<p class="lead">Inspect hostnames, verify domain claims, rotate links, run cleanup, and review router update events.</p>
<p class="lead">Review hostnames, domain checks, updates, cleanup, and secret changes.</p>
</div>
</section>
@@ -96,17 +89,15 @@ def _render_admin_page(
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<p class="eyebrow">Service health</p>
<h2>Scheduled cleanup</h2>
<p class="section-copy">Cleanup runs every {escape(settings.cleanup_interval_seconds)} seconds. It removes unverified domain claims after {escape(settings.cleanup_challenge_hours)} hours and never-used generated hostnames after {escape(settings.cleanup_unused_account_hours)} hours.</p>
<p class="section-copy">The scheduler state is stored in SQLite. Multiple app workers coordinate with a database lease, so only one worker runs a due cleanup task.</p>
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row mb-4">
<div>
<p class="eyebrow">Service health</p>
<h2>Service status</h2>
</div>
</div>
<form method="post" action="{safe_admin_path}/cleanup" class="col-lg-5 card card-body gap-3" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run cleanup now</button>
</form>
{cleanup_panel}
<div class="mt-4">
{metrics_html}
</div>
</div>
</section>
@@ -116,15 +107,15 @@ def _render_admin_page(
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Hostnames</p>
<h2>Active credentials</h2>
<p class="section-copy mb-0">Showing up to {escape(len(accounts))} of {accounts_count} hostnames.</p>
<h2>Hostnames</h2>
<p class="section-copy mb-0">Showing {escape(len(accounts))} of {accounts_count}.</p>
</div>
</div>
{_admin_table_toolbar("hostnames", "Search hostnames")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
<tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th>Links</th><th></th></tr>
<tr><th scope="col">Domain</th><th scope="col">User</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
@@ -139,14 +130,14 @@ def _render_admin_page(
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Domain claims</p>
<h2>Verification lifecycle</h2>
<p class="section-copy mb-0">Showing up to {escape(len(domain_challenges))} of {domain_count} claims.</p>
<h2>Domain verification</h2>
<p class="section-copy mb-0">Showing {escape(len(domain_challenges))} of {domain_count}.</p>
</div>
</div>
{_admin_table_toolbar("domain-claims", "Search domain claims")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Domain</th><th>Owner dashboard</th><th>Status</th><th>Created</th><th>Verified</th></tr></thead>
<thead><tr><th scope="col">Domain</th><th scope="col">Owner dashboard</th><th scope="col">Status</th><th scope="col">Created</th><th scope="col">Verified</th></tr></thead>
<tbody>{domain_rows}</tbody>
</table>
</div>
@@ -155,52 +146,19 @@ def _render_admin_page(
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Scheduler state</h2>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll mb-4">
<table class="table mb-0">
<thead><tr><th>Task</th><th>Status</th><th>Next run</th><th>Lease expires</th><th>Last finished</th><th>Last error</th></tr></thead>
<tbody>{scheduled_task_rows}</tbody>
</table>
</div>
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Recent runs</h2>
<p class="section-copy mb-0">Showing up to {escape(len(cleanup_runs))} of {cleanup_count} runs.</p>
</div>
</div>
{_admin_table_toolbar("cleanup-runs", "Search cleanup runs")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain claims removed</th><th>Unused hostnames removed</th></tr></thead>
<tbody>{cleanup_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{cleanup_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Updates</p>
<h2>Recent events</h2>
<p class="section-copy mb-0">Showing up to {escape(len(update_events))} of {event_count} event rows. Repeated unchanged polls are aggregated.</p>
<h2>Update events</h2>
<p class="section-copy mb-0">Showing {escape(len(update_events))} of {event_count}. Repeated checks with no IP change are grouped.</p>
</div>
</div>
{_admin_table_toolbar("update-events", "Search update events")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<thead><tr><th scope="col">Time</th><th scope="col">Domain</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
@@ -210,6 +168,8 @@ def _render_admin_page(
</section>
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
)
@@ -227,7 +187,103 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-3" aria-label="Server-side table pages">
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous server page</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next server page</a>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a>
</nav>
""")
def _cleanup_panel(
settings: DdnsSettings,
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
safe_admin_path: str,
csrf: str,
) -> SafeHtml:
task = next((item for item in scheduled_tasks if item.get("name") == "cleanup"), scheduled_tasks[0] if scheduled_tasks else {})
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
next_run = task.get("next_run_at")
status = task.get("last_status") or "pending"
last_error = task.get("last_error") or ""
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
domains_removed = last_cleanup.get("domain_challenges_deleted", 0)
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
error_html = (
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
if last_error
else trusted("")
)
cards = [
("Interval", f"*/{interval}s"),
("Last run", str(last_run or "Never")),
("Next run", str(next_run or "Pending")),
("Status", str(status)),
]
card_html = "\n".join(
f"""
<div class="col-12 col-md-6 col-xl-3">
<div class="metric-card h-100">
<span>{escape(label)}</span>
<strong>{escape(value)}</strong>
</div>
</div>
"""
for label, value in cards
)
return trusted(f"""
<div class="card flat-card">
<div class="card-body p-4">
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row">
<div>
<h3 class="h5 mb-2">Cleanup</h3>
<p class="section-copy mb-1">Last cleanup removed {escape(domains_removed)} domain claims and {escape(accounts_removed)} unused hostnames.</p>
{error_html}
</div>
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
</form>
</div>
<div class="row g-3 mt-3">
{card_html}
</div>
</div>
</div>
""")
def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
samples = metrics.get("update_latency_samples_total", 0)
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
items = [
("Updates", _metric_int(metrics.get("update_requests_total", 0))),
("Updated", _metric_int(metrics.get("update_success_total", 0))),
("DNS failures", _metric_int(metrics.get("dns_failures_total", 0))),
("DNS check failures", _metric_int(metrics.get("dns_reconcile_failures_total", 0))),
("Blocked requests", _metric_int(metrics.get("rate_limit_hits_total", 0))),
("Average update time", f"{average_latency:.1f} ms"),
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
("Cleanup ok/errors", f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}"),
]
cards = "\n".join(
f"""
<div class="col-6 col-lg-3">
<div class="metric-card h-100">
<span>{escape(label)}</span>
<strong>{escape(value)}</strong>
</div>
</div>
"""
for label, value in items
)
return trusted(f"""
<div class="mt-4">
<p class="eyebrow">Metrics</p>
<div class="row g-3 mt-1">{cards}</div>
</div>
""")
def _metric_int(value: float) -> str:
return str(int(value))
+4 -4
View File
@@ -17,16 +17,16 @@ def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_pat
<form method="post" action="{safe_admin_path}/accounts/rotate" class="d-flex gap-2 flex-wrap">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Rotate update URL" aria-label="Rotate update URL for {hostname}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Rotate management link" aria-label="Rotate management link for {hostname}">Manage link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Rotate router password" aria-label="Rotate router password for {hostname}">Password</button>
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Create a new router password" aria-label="New password for {hostname}">New password</button>
</form>
</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button class="btn btn-sm btn-light px-2 text-danger" title="Delete hostname" aria-label="Delete {hostname}">x</button>
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button>
</form>
</td>
</tr>
+31 -30
View File
@@ -57,10 +57,10 @@ def _render_management_page(
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Magic management</p>
<p class="eyebrow">Dashboard</p>
<h1>{hostname}</h1>
<p class="lead">Anyone with this link can manage or delete this hostname. Keep it private.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Back home</a>
<p class="lead">This private link can manage this hostname. Keep it safe.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
</section>
{message_html}
@@ -71,12 +71,12 @@ def _render_management_page(
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<h2>Router update settings</h2>
<p class="section-copy">Use the Update-URL directly if your router only has one URL field. Separate Benutzername and Kennwort values are optional compatibility fields. If you use your own DNS name, point it at this hostname with a CNAME.</p>
<p class="section-copy">Use the Update-URL if your router has one URL field. Username and password are only for routers that require them. For your own subdomain, create a CNAME to this hostname.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap mt-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy CNAME record", cname_record)}
{_copy_button("Copy router fields", fritzbox_fields)}
{_copy_button("Copy CNAME", cname_record)}
</div>
</div>
<div class="col-lg-6 d-grid gap-2">
@@ -91,19 +91,19 @@ def _render_management_page(
<div class="card card-body gap-3 mt-4">
<div>
<p class="eyebrow">Actions</p>
<h3>Rotate dashboard secrets</h3>
<p class="section-copy">Rotate the URL used by routers, or issue a new router password for compatibility clients. Update your router after rotating.</p>
<h3>Replace router secrets</h3>
<p class="section-copy">Create a new update URL or router password. Update your router after changing either one.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate Update-URL</button>
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
</form>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="password">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate router credentials</button>
<button class="btn btn-outline-primary rounded-pill" type="submit">New password</button>
</form>
</div>
</div>
@@ -134,7 +134,7 @@ def _render_management_page(
<div>
<p class="eyebrow">History</p>
<h2>Router updates</h2>
<p class="section-copy">Recent IP changes and failed DNS publishes for this hostname.</p>
<p class="section-copy">Recent IP changes and failed updates for this hostname.</p>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll">
@@ -148,7 +148,7 @@ def _render_management_page(
<section class="section danger-section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Danger zone</p>
<p class="eyebrow">Delete</p>
<h2>Delete hostname</h2>
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
</div>
@@ -160,6 +160,7 @@ def _render_management_page(
</section>
</main>
"""),
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
)
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"""
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password/remove">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label class="form-label" for="dashboard-remove-current-password">Current dashboard password</label>
<label class="form-label" for="dashboard-remove-current-password">Current password</label>
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove dashboard password</button>
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove password</button>
</form>
"""
current_password_field = ""
if password_enabled:
current_password_field = """
<div>
<label class="form-label" for="dashboard-current-password">Current dashboard password</label>
<label class="form-label" for="dashboard-current-password">Current password</label>
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
@@ -203,7 +204,7 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
<div class="col-lg-5">
<p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2>
<p class="section-copy">Status: {escape(password_status)}. This protects this management dashboard only. Router update URLs continue to work normally.</p>
<p class="section-copy">Status: {escape(password_status)}. This password protects only this dashboard. Router updates still work.</p>
</div>
<div class="col-lg-7 d-grid gap-3">
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password">
@@ -249,7 +250,7 @@ def _render_management_unlock_page(
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Dashboard locked</p>
<h1>{hostname}</h1>
<p class="lead">Enter the dashboard password to manage this hostname.</p>
<p class="lead">Enter the dashboard password for this hostname.</p>
</div>
</section>
<section class="section">
@@ -263,9 +264,9 @@ def _render_management_unlock_page(
</div>
<label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
<span class="form-check-label">Stay unlocked for 30 days on this browser</span>
<span class="form-check-label">Remember this browser for 30 days</span>
</label>
<button class="btn btn-primary rounded-pill" type="submit">Unlock dashboard</button>
<button class="btn btn-primary rounded-pill" type="submit">Unlock</button>
</form>
</div>
</section>
@@ -285,15 +286,15 @@ def _management_domain_claim_panel(
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Custom domain</p>
<h2>Claim a domain</h2>
<p class="section-copy">Create a TXT challenge, verify it from this dashboard, then create router credentials for a hostname under that verified domain.</p>
<h2>Use your domain</h2>
<p class="section-copy">Create a TXT record, verify it here, then create a hostname under that domain.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/request" class="d-grid gap-3" aria-label="Create dashboard domain claim">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create domain claim</button>
<button type="submit" class="btn btn-primary rounded-pill">Create claim</button>
</form>
</div>
"""
@@ -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(
<p class="eyebrow">Domain claim</p>
<h2>{escape(domain)}</h2>
<p class="section-copy">Status: {escape(status)}</p>
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Another dashboard cannot verify or use it.</p>
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Other dashboards cannot use it.</p>
</div>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The claim secret and private claim link are secrets.</div>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. Claim secret and private claim link are secret.</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy TXT record", txt_bundle)}
{_copy_button("Copy TXT", txt_bundle)}
</div>
<div class="d-grid gap-2">
{_copy_row("TXT name", verification_name, "Public")}
@@ -341,7 +342,7 @@ def _management_domain_claim_panel(
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="domain" value="{attr(domain)}">
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<button type="submit" class="btn btn-primary rounded-pill">Verify claim</button>
<button type="submit" class="btn btn-primary rounded-pill">Verify</button>
</form>
{create_hostname}
</div>
@@ -358,10 +359,10 @@ def _management_custom_hostname_form(management_slug: str, claim_secret: str, cs
<form method="post" action="/m/{attr(management_slug)}/domains/hostnames" class="d-grid gap-3" aria-label="Create verified custom hostname">
{csrf_field}
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<label>Custom hostname
<label>Hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create custom hostname</button>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
"""
)
+66 -24
View File
@@ -14,11 +14,13 @@ def _render_public_home(
message: str | None,
challenge: dict[str, str | None] | None = None,
) -> str:
base_url = settings.public_base_url.rstrip("/")
suffix = escape(settings.hostname_suffix or "your DynDNS domain")
demo_update_url = escape(f"{settings.public_base_url.rstrip('/')}/u/<secret>?myip=<ipaddr>")
demo_update_url = escape(f"{base_url}/u/<secret>?myip=<ipaddr>")
created_html = _credentials_panel(service, created_account)
message_html = _message_band(message) if message and not challenge else ""
challenge_html = _custom_domain_flow(service, challenge, message)
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, custom domains, Linux cron jobs, and home networks."
return _page(
"KarlDNS",
trusted(f"""
@@ -26,15 +28,15 @@ def _render_public_home(
{_top_nav()}
<section class="hero-band">
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.png" alt="" width="80" height="80">
<h1>KarlDNS for home routers.</h1>
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
<h1>Dynamic DNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">Generate one secret update URL under {suffix}. Paste it into your router, script it with curl, or point your own subdomain at it with a CNAME.</p>
<p class="lead">KarlDNS gives you a private DynDNS update URL under {suffix}. Add it to your router, or use it from a script.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<a class="btn btn-primary rounded-pill" href="#create">Generate hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">See curl flow</a>
<a class="btn btn-primary rounded-pill" href="#create">Create hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
</div>
</div>
</section>
@@ -43,31 +45,31 @@ def _render_public_home(
<section class="section" id="create">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Choose your hostname</p>
<h2>No account. No login.</h2>
<p class="section-copy mx-auto">Use a generated KarlDNS hostname immediately, or verify your own domain with a DNS TXT record.</p>
<p class="eyebrow">Hostname</p>
<h2>No account needed.</h2>
<p class="section-copy mx-auto">Use a KarlDNS hostname now, or verify your own domain when you need it.</p>
</div>
<div class="row g-4 align-items-stretch">
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use our domain</p>
<h3>Generate a KarlDNS hostname</h3>
<p class="section-copy mb-0">KarlDNS generates the hostname, secret update URL, and optional router compatibility credentials automatically.</p>
<p class="eyebrow">KarlDNS domain</p>
<h3>Create a hostname</h3>
<p class="section-copy mb-0">You get a hostname, a private update URL, and router fields if your router needs them.</p>
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Generate hostname</button>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
</div>
</div>
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use your own domain</p>
<h3>Verify DNS ownership</h3>
<p class="section-copy mb-0">Add one TXT record so KarlDNS can confirm you control the domain before it publishes A/AAAA records there.</p>
<h3>Verify your domain</h3>
<p class="section-copy mb-0">Add one TXT record first. KarlDNS only updates domains it can verify.</p>
<form method="post" action="/request-domain" class="d-grid gap-3" aria-label="Request a custom domain TXT challenge">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create TXT challenge</button>
<button type="submit" class="btn btn-primary rounded-pill">Create TXT record</button>
</form>
</div>
</div>
@@ -80,8 +82,8 @@ def _render_public_home(
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from anything that can send GET.</h2>
<p class="lead mx-auto mt-3">Use FRITZ!Box placeholders, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, or your own automation.</p>
<h2>Update from a router or script.</h2>
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
<div class="terminal-card text-start mt-4">
<code># explicit IPv4</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
@@ -94,9 +96,33 @@ def _render_public_home(
{challenge_html}
</main>
"""),
description=description,
preload_logo=True,
canonical_url=f"{base_url}/",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
structured_data={
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "KarlDNS",
"applicationCategory": "NetworkingApplication",
"operatingSystem": "Linux",
"url": f"{base_url}/",
"description": description,
"image": f"{base_url}/social-preview.png",
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
"featureList": [
"Dynamic DNS for FRITZ!Box routers",
"Accountless private update URLs",
"Custom domain TXT verification",
"Linux cron updater",
],
},
)
def _render_privacy_page() -> str:
def _render_privacy_page(settings: DdnsSettings) -> str:
base_url = settings.public_base_url.rstrip("/")
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, DNS verification records, IP history, and cleanup behavior."
return _page(
"Legal and Privacy",
trusted(f"""
@@ -105,23 +131,23 @@ def _render_privacy_page() -> str:
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Legal and privacy</p>
<h1>KarlDNS keeps accountless DynDNS simple.</h1>
<p class="lead">This page explains the basic data handled by this self-hosted service. Configure your own imprint, controller details, and jurisdiction-specific terms before offering it publicly.</p>
<h1>Privacy basics.</h1>
<p class="lead">KarlDNS stores only the data needed to keep your hostname updated.</p>
</div>
</section>
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores generated hostnames, public DNS target names, update slugs, hashed management tokens, hashed router passwords, DNS TXT verification challenges, IP update history, timestamps, and operational events needed to run Dynamic DNS.</p>
<p class="section-copy">KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, TXT verification records, recent IP history, timestamps, and service events.</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Private management links, update URLs, router passwords, and one-box update URLs are bearer secrets. Anyone with them can update or manage the related hostname. Passwords and management lookup values are not stored in recoverable plaintext.</p>
<p class="section-copy">Dashboard links, update URLs, router passwords, full update URLs, and claim links are secrets. Anyone with them may update or manage the hostname. Passwords and dashboard lookup values are not stored as readable text.</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">The service keeps recent update events for troubleshooting and runs cleanup jobs for abandoned setup flows and unused hostnames. Server and reverse-proxy logs may also contain IP addresses and request metadata depending on your deployment.</p>
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes abandoned setup records and unused hostnames. Server logs may also include IP addresses and request details.</p>
</div>
<div>
<h2>Contact</h2>
@@ -131,4 +157,20 @@ def _render_privacy_page() -> str:
</section>
</main>
"""),
description=description,
canonical_url=f"{base_url}/privacy",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS legal and privacy information",
structured_data={
"@context": "https://schema.org",
"@type": "WebPage",
"name": "KarlDNS Legal and Privacy",
"url": f"{base_url}/privacy",
"description": description,
"isPartOf": {
"@type": "WebSite",
"name": "KarlDNS",
"url": f"{base_url}/",
},
},
)
+23 -23
View File
@@ -25,8 +25,8 @@ def _linux_installer_section() -> SafeHtml:
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p>
<h2>Install and uninstall should be boring.</h2>
<p class="section-copy mx-auto">For a Linux server, Proxmox host, NAS, or home-lab box that is always on, the installer creates a hostname if needed, installs the updater, schedules cron, runs the first update, and prints the private dashboard URL.</p>
<h2>Linux updater.</h2>
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates a hostname, installs the updater, schedules it, and prints your private dashboard link.</p>
</div>
<div class="card card-body gap-3">
<div class="terminal-card">
@@ -39,11 +39,11 @@ def _linux_installer_section() -> SafeHtml:
{_copy_button("Copy all commands", all_commands)}
</div>
<div class="alert alert-info my-0 py-2 small" role="note">
Uninstall keeps the private config by default because it may contain your only local copy of the update and dashboard URLs. Use <code>--delete-config</code> for a clean wipe.
Uninstall keeps your private URLs by default. Use <code>--delete-config</code> only when you want to remove everything.
</div>
<div>
<h3>Without sudo</h3>
<p class="section-copy">User mode writes to <code>~/.local/bin</code>, <code>~/.config/karldns</code>, and your user crontab.</p>
<h3>User install</h3>
<p class="section-copy">Use this when you do not want a system-wide install.</p>
</div>
<div class="terminal-card">
<code>{escape(user_install)}</code>
@@ -69,7 +69,7 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
claim_secret = attr(challenge.get("claim_secret") or "")
verification_name = service.verification_name(str(challenge["domain"]))
claim_url = service.domain_claim_url(challenge)
status = escape(message or "Add this TXT record at your DNS provider, then check when it has propagated.")
status = escape(message or "Add this TXT record at your DNS provider, then check again. DNS can take a few minutes.")
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
challenge_html = trusted(
f"""
@@ -78,8 +78,8 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
<span class="badge text-bg-primary rounded-circle align-self-start">1</span>
<h3>Add the TXT record</h3>
<p class="small text-secondary mb-0 mt-2">{status}</p>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The private claim link is secret.</div>
{_copy_button("Copy TXT record", txt_bundle)}
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. The private claim link is secret.</div>
{_copy_button("Copy TXT", txt_bundle)}
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Private claim link", claim_url, "Secret")}
@@ -99,9 +99,9 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
<section class="section" id="custom-domain">
<div class="container narrow">
<div class="text-center">
<p class="eyebrow">Direct custom-domain publishing</p>
<h2>Verify DNS ownership</h2>
<p class="section-copy mx-auto">Use this only when KarlDNS should publish A/AAAA records directly inside your domain. If you just want your own subdomain, generate a hostname above and create a CNAME to it.</p>
<p class="eyebrow">Custom domain</p>
<h2>Verify your domain</h2>
<p class="section-copy mx-auto">Use this when KarlDNS should update records inside your domain. For a simpler setup, create a KarlDNS hostname and point a CNAME to it.</p>
</div>
<div class="row g-3 mt-4 justify-content-center">
{challenge_html}
@@ -129,24 +129,24 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Hostname generated</p>
<p class="eyebrow">Hostname ready</p>
<h2>{escape(hostname)}</h2>
<p class="section-copy">Use the Update-URL directly. The separate Benutzername and Kennwort values are optional compatibility fields for routers that require them.</p>
<p class="section-copy">Use the Update-URL directly. Username and password are only for routers that require separate fields.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap my-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy one-box update URL", credentialed_update_url)}
{_copy_button("Copy CNAME record", cname_record)}
{_copy_button("Copy router fields", fritzbox_fields)}
{_copy_button("Copy full URL", credentialed_update_url)}
{_copy_button("Copy CNAME", cname_record)}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("One-box update URL:", credentialed_update_url, "Secret")}
{_copy_row("Full update URL:", credentialed_update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", password, "Secret")}
{_copy_row("Private status page:", management_url, "Secret")}
{_copy_row("Private dashboard:", management_url, "Secret")}
</div>
{_manual_update_panel(manual_commands)}
</div>
@@ -161,15 +161,15 @@ def _custom_hostname_form(claim_secret: str) -> SafeHtml:
<div class="col-lg-5">
<div class="card card-body h-100 gap-3">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create router hostname</h3>
<h3>Create hostname</h3>
<form method="post" action="/accounts" class="d-grid gap-3" aria-label="Create custom-domain router credentials">
<input type="hidden" name="mode" value="custom">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<label>Verified hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<p class="section-copy mb-0">Router compatibility credentials are generated automatically after creation.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create credentials</button>
<p class="section-copy mb-0">Router fields are created after this step.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
</div>
</div>
@@ -183,8 +183,8 @@ def _locked_custom_hostname_card() -> SafeHtml:
<div class="col-lg-5">
<div class="card card-body h-100 gap-3 opacity-75">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create router hostname</h3>
<p class="small text-secondary mb-0 mt-2">This unlocks after the TXT record is visible in public DNS.</p>
<h3>Create hostname</h3>
<p class="small text-secondary mb-0 mt-2">Available after KarlDNS can see the TXT record.</p>
</div>
</div>
"""
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = ROOT / "router_dyndns" / "assets" / "bootstrap-subset.css"
SCAN_PATHS = [
ROOT / "router_dyndns" / "web",
ROOT / "router_dyndns" / "routers",
]
CSS = r"""
: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(--bLine truncated
""".strip()
SUPPORTED_DYNAMIC_CLASSES = {"active", "disabled", "page-item", "page-link"}
def _class_names_from_css(css: str) -> set[str]:
return set(re.findall(r"\.([_a-zA-Z][_a-zA-Z0-9-]*)", css))
def _class_names_from_templates() -> set[str]:
class_names: set[str] = set()
for scan_path in SCAN_PATHS:
for path in scan_path.glob("*.py"):
text = path.read_text()
for match in re.finditer(r"class=[\"']([^\"']+)[\"']", text):
for class_name in match.group(1).split():
if "{" not in class_name and "}" not in class_name:
class_names.add(class_name)
return class_names
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build and validate the tiny KarlDNS Bootstrap compatibility subset.")
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser.parse_args()
def main() -> int:
args = parse_args()
output = args.output if args.output.is_absolute() else ROOT / args.output
app_styles = (ROOT / "router_dyndns" / "web" / "styles.py").read_text()
supported = _class_names_from_css(CSS) | _class_names_from_css(app_styles) | SUPPORTED_DYNAMIC_CLASSES
unsupported = sorted(_class_names_from_templates() - supported)
if unsupported:
unsupported_text = "\n".join(f"- {class_name}" for class_name in unsupported)
raise SystemExit(f"Unsupported Bootstrap-style classes in templates:\n{unsupported_text}")
output.write_text(CSS + "\n")
print(f"Wrote {output.relative_to(ROOT)} ({output.stat().st_size} bytes)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9 -9
View File
@@ -22,12 +22,12 @@ Usage:
Options:
--base-url URL KarlDNS server URL. Default: https://karldns.de
--raw-base-url URL Script download base URL. Default: GitHub raw main branch
--every MINUTES Cron interval, 1-60. Default: 5
--raw-base-url URL Updater script download URL
--every MINUTES Update interval, 1-60. Default: 5
--mode MODE auto, system, or user. Default: auto
--update-url URL Existing private /u/<secret> update URL
--hostname NAME Optional display hostname for output/config
--dashboard-url URL Optional private dashboard URL for output/config
--update-url URL Existing private update URL
--hostname NAME Hostname to show after install
--dashboard-url URL Private dashboard URL to show after install
--ipv4 VALUE auto, off, or a static IPv4. Default: auto
--ipv6 VALUE auto, off, or a static IPv6. Default: auto
--no-cron Install files only, do not configure cron
@@ -202,7 +202,7 @@ case "$KARLDNS_INSTALL_MODE" in
esac
if [ "$KARLDNS_INSTALL_MODE" = "system" ] && [ "$(id -u)" -ne 0 ]; then
echo "System install needs root. Re-run with sudo or set KARLDNS_INSTALL_MODE=user." >&2
echo "System install needs root. Re-run with sudo or use --mode user." >&2
exit 1
fi
@@ -215,7 +215,7 @@ if [ -z "$KARLDNS_UPDATE_URL" ]; then
fi
if [ -z "$KARLDNS_UPDATE_URL" ]; then
echo "KarlDNS installer: could not get an update URL" >&2
echo "KarlDNS installer: could not create an update URL" >&2
exit 1
fi
case "$KARLDNS_UPDATE_URL" in
@@ -230,10 +230,10 @@ fi
echo "KarlDNS installed."
echo "Hostname: ${KARLDNS_HOSTNAME:-already configured}"
echo "Dashboard: ${KARLDNS_MANAGEMENT_URL:-use your existing private status page}"
echo "Dashboard: ${KARLDNS_MANAGEMENT_URL:-use your existing private dashboard}"
echo "Mode: $KARLDNS_INSTALL_MODE"
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
echo "Interval: every $KARLDNS_INTERVAL_MINUTES minute(s)"
echo "Interval: every $KARLDNS_INTERVAL_MINUTES minutes"
else
echo "Cron: skipped"
fi
+4 -4
View File
@@ -8,7 +8,7 @@ if [ -r "$CONFIG_FILE" ]; then
. "$CONFIG_FILE"
fi
: "${KARLDNS_UPDATE_URL:?KARLDNS_UPDATE_URL is required in $CONFIG_FILE or the environment}"
: "${KARLDNS_UPDATE_URL:?Set KARLDNS_UPDATE_URL in $CONFIG_FILE or the environment}"
KARLDNS_BASE_URL="${KARLDNS_BASE_URL:-https://karldns.de}"
KARLDNS_LOCK_FILE="${KARLDNS_LOCK_FILE:-/tmp/karldns-updater.lock}"
KARLDNS_CACHE_FILE="${KARLDNS_CACHE_FILE:-${XDG_CACHE_HOME:-$HOME/.cache}/karldns/last-ip}"
@@ -49,8 +49,8 @@ elif [ "$KARLDNS_IPV6" != "off" ]; then
ipv6="$KARLDNS_IPV6"
fi
case "$ipv4" in *" "*) echo "KarlDNS: invalid IPv4 discovery result" >&2; exit 1 ;; esac
case "$ipv6" in *" "*) echo "KarlDNS: invalid IPv6 discovery result" >&2; exit 1 ;; esac
case "$ipv4" in *" "*) echo "KarlDNS: could not read a valid IPv4 address" >&2; exit 1 ;; esac
case "$ipv6" in *" "*) echo "KarlDNS: could not read a valid IPv6 address" >&2; exit 1 ;; esac
public_ip="${ipv4}|${ipv6}"
has_discovered_ip=0
if [ -n "$ipv4" ] || [ -n "$ipv6" ]; then
@@ -87,7 +87,7 @@ case "$response" in
echo "KarlDNS: $response"
;;
*)
echo "KarlDNS: update failed: $response" >&2
echo "KarlDNS: update failed. Server said: $response" >&2
exit 1
;;
esac
+2 -2
View File
@@ -13,7 +13,7 @@ Usage:
Options:
--mode MODE auto, system, or user. Default: auto
--delete-config Also delete the private updater config and last-IP cache
--delete-config Also delete saved private URLs and the last-IP cache
-h, --help Show this help
EOF
}
@@ -80,5 +80,5 @@ fi
echo "KarlDNS uninstalled from $KARLDNS_INSTALL_MODE mode."
if [ "$KARLDNS_DELETE_CONFIG" = "0" ]; then
echo "Private config was kept. Re-run with --delete-config to remove it."
echo "Saved private URLs were kept. Re-run with --delete-config to remove them."
fi
+66 -13
View File
@@ -27,20 +27,24 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert '<link href="/admin.css?v=2" rel="stylesheet">' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' in response.text
assert "Operator console" in response.text
assert "Generate credentials" not in response.text
assert 'action="/admin/accounts"' not in response.text
assert response.text.count("data-admin-table") == 4
assert response.text.count("data-table-search") == 4
assert response.text.count("data-table-page-size") == 4
assert response.text.count("data-table-pagination") == 4
assert response.text.count("data-admin-table") == 3
assert response.text.count("data-table-search") == 3
assert response.text.count("data-table-page-size") == 3
assert response.text.count("data-table-pagination") == 3
assert "admin-table-scroll" in response.text
assert "Scheduled cleanup" in response.text
assert "Scheduler state" in response.text
assert "database lease" in response.text
assert "Active credentials" in response.text
assert "Cleanup" in response.text
assert "Run now" in response.text
assert "Next run" in response.text
assert "Metrics" in response.text
assert "Blocked requests" in response.text
assert "Hostnames" in response.text
assert "Domain claims" in response.text
assert "Recent events" in response.text
assert "Update events" in response.text
assert "example.net" in response.text
@@ -89,10 +93,59 @@ def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
assert len(records["items"]) == 1
assert events["limit"] == 1
assert events["offset"] == 1
assert "next_before" in events
assert events["total"] == 2
assert len(events["items"]) == 1
def test_admin_records_and_events_reject_excessive_offsets(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
records = client.get("/records?offset=10001", auth=("admin", "admin"))
events = client.get("/events?offset=10001", auth=("admin", "admin"))
assert records.status_code == 422
assert events.status_code == 422
def test_update_events_page_orders_raw_and_rollup_events_in_sql(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
store.log_update_event("raw-old.example.net", "203.0.113.1", None, "updated", "ok", "198.51.100.1")
store.log_update_event("raw-new.example.net", "203.0.113.2", None, "updated", "ok", "198.51.100.2")
store.log_update_rollup("rollup-mid.example.net", "203.0.113.3", None, "unchanged", "ok", "198.51.100.3")
with store.connect() as conn:
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-01T00:00:00+00:00", "raw-old.example.net"))
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-03T00:00:00+00:00", "raw-new.example.net"))
conn.execute(
"UPDATE update_event_rollups SET last_seen_at = ? WHERE hostname = ?",
("2026-01-02T00:00:00+00:00", "rollup-mid.example.net"),
)
page = store.list_update_events_page(limit=2, offset=1)
assert [event["hostname"] for event in page] == ["rollup-mid.example.net", "raw-old.example.net"]
def test_update_events_support_keyset_pagination(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.log_update_event("raw-old.example.net", "203.0.113.1", None, "updated", "ok", "198.51.100.1")
store.log_update_event("raw-new.example.net", "203.0.113.2", None, "updated", "ok", "198.51.100.2")
with store.connect() as conn:
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-01T00:00:00+00:00", "raw-old.example.net"))
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-02T00:00:00+00:00", "raw-new.example.net"))
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
first = client.get("/events?limit=1", auth=("admin", "admin")).json()
second = client.get(f"/events?limit=1&before={first['next_before']}", auth=("admin", "admin")).json()
assert first["items"][0]["hostname"] == "raw-new.example.net"
assert second["items"][0]["hostname"] == "raw-old.example.net"
def test_admin_posts_require_csrf(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
@@ -150,7 +203,7 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
)
assert update.status_code == 200
assert "Update URL rotated" in update.text
assert "Update URL changed" in update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
management = client.post(
@@ -159,7 +212,7 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
)
assert management.status_code == 200
assert "Private status page rotated" in management.text
assert "Dashboard link changed" in management.text
assert "/m/" in management.text
password = client.post(
@@ -168,8 +221,8 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
)
assert password.status_code == 200
assert "Router password rotated" in password.text
assert "One-box update URL:" in password.text
assert "Router password changed" in password.text
assert "Full update URL:" in password.text
assert "/nic/update?" in password.text
assert "Kennwort (optional):" in password.text
statuses = {event["status"] for event in store.list_update_events_for_hostname("home.example.net", 10)}
+4
View File
@@ -46,6 +46,9 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
assert docs.status_code == 200
assert redoc.status_code == 200
assert docs.headers["x-robots-tag"] == "noindex, nofollow"
assert redoc.headers["x-robots-tag"] == "noindex, nofollow"
assert client.get("/openapi.json").headers["x-robots-tag"] == "noindex, nofollow"
assert "unsafe-inline" in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
@@ -181,6 +184,7 @@ def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
assert created.headers["x-robots-tag"] == "noindex, nofollow"
body = created.json()
assert body["hostname"].endswith(".ddns.example.net")
assert body["update_url"].startswith("http://ddns.example.net/u/")
+1
View File
@@ -104,6 +104,7 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
"rate_limits",
"records",
"scheduled_tasks",
"service_metrics",
"update_events",
}.issubset(tables)
assert len(store.list_scheduled_tasks()) == 1
+149 -3
View File
@@ -1,3 +1,5 @@
import hashlib
import hmac
import logging
from pathlib import Path
@@ -5,8 +7,9 @@ import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
from router_dyndns.ddns_dns import validate_dns_provider_settings
from router_dyndns.ddns_security import DASHBOARD_UNLOCK_COOKIE
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
@@ -74,11 +77,92 @@ def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
)
client = TestClient(app)
first = client.get("/m/not-a-real-token")
second = client.get("/m/another-token")
first = client.get(f"/m/{'a' * 43}")
second = client.get(f"/m/{'b' * 43}")
assert first.status_code == 404
assert second.status_code == 429
assert app.state.store.get_service_metrics()["rate_limit_hits_total"] == 1
def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path, caplog) -> None:
caplog.set_level(logging.INFO, logger="router_dyndns.access")
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
redacted_access_log=True,
)
)
account = app.state.store.create_account("home.example.net", "router")
client = TestClient(app)
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.1"})
client.get(
"/nic/update",
params={
"hostname": account["hostname"],
"username": account["username"],
"password": account["password"],
"myip": "203.0.113.2",
},
)
assert "/u/<secret>" in caplog.text
assert "/nic/update?<redacted>" in caplog.text
assert account["update_slug"] not in caplog.text
assert account["password"] not in caplog.text
@pytest.mark.parametrize(
"path",
[
"/u/short",
"/api/v1/updates/short",
"/m/short",
"/api/v1/management/short",
"/d/short",
],
)
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get(path)
assert response.status_code == 422
def test_dashboard_unlock_cookie_rejects_password_hash_signed_forgery(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "router")
store.set_dashboard_password("home.example.net", "correct horse")
settings = DdnsSettings(
database_path=database_path,
admin_password="admin",
dashboard_cookie_secret="s" * 32,
)
app = make_app(settings)
client = TestClient(app)
stored = store.get_account_by_management_slug(account["management_slug"])
password_hash = str(stored["dashboard_password_hash"])
timestamp = 1_900_000_000
max_age = settings.dashboard_unlock_seconds
payload = f"{account['hostname']}\0{account['management_slug']}\0{timestamp}\0{max_age}".encode()
forged = f"v2:{timestamp}:{max_age}:{hmac.new(password_hash.encode(), payload, hashlib.sha256).hexdigest()}"
client.cookies.set(DASHBOARD_UNLOCK_COOKIE, forged, path=f"/m/{account['management_slug']}")
response = client.get(f"/m/{account['management_slug']}")
assert response.status_code == 200
assert "Dashboard locked" in response.text
assert "Update-URL:" not in response.text
def test_settings_reject_unknown_constructor_fields() -> None:
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
DdnsSettings(admn_password="typo")
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
@@ -246,6 +330,40 @@ def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) ->
)
def test_required_dns_provider_requires_strong_dashboard_cookie_signing_source(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_DASHBOARD_COOKIE_SECRET"):
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="a" * 16,
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="rfc2136",
rfc2136_server="192.0.2.53",
rfc2136_zone="example.net",
require_dns_provider=True,
)
)
def test_required_dns_provider_accepts_explicit_dashboard_cookie_secret(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="a" * 16,
dashboard_cookie_secret="s" * 32,
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="rfc2136",
rfc2136_server="192.0.2.53",
rfc2136_zone="example.net",
require_dns_provider=True,
)
)
assert app.state.settings.dashboard_cookie_secret == "s" * 32
def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE"):
make_app(
@@ -261,6 +379,34 @@ def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> N
)
def test_cloudflare_publish_reconciles_provider_state(monkeypatch) -> None:
import router_dyndns.dns.cloudflare as cloudflare
calls = {"find": 0}
def fake_find(settings, hostname, record_type):
calls["find"] += 1
if calls["find"] == 1:
return None
return {"id": "record-id", "content": "203.0.113.99"}
monkeypatch.setattr(cloudflare, "_cloudflare_find_record", fake_find)
monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **kwargs: {"success": True, "result": []})
with pytest.raises(DnsReconciliationError, match="did not match"):
cloudflare.publish_cloudflare(
UpdateResult(
hostname="home.example.net",
ipv4="203.0.113.10",
ipv6=None,
changed=True,
update_ipv4=True,
update_ipv6=False,
),
DdnsSettings(cloudflare_api_token="token", cloudflare_zone_id="zone"),
)
def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
import dns.query
import dns.rcode
+97 -33
View File
@@ -28,12 +28,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "Copy router fields" in response.text
assert "Copy CNAME" in response.text
assert "Manual updates" in response.text
assert "curl -sS" in response.text
assert "203.0.113.10" in response.text
@@ -61,15 +61,15 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Private status page:" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "FRITZ!Box replaces" in response.text
assert "Copy curl examples" in response.text
assert "Private dashboard:" in response.text
assert "Copy router fields" in response.text
assert "Copy CNAME" in response.text
assert "FRITZ!Box fills in" in response.text
assert "Copy curl" in response.text
assert response.text.count("Secret") >= 3
assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text
@@ -94,12 +94,13 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
management = client.get(f"/m/{account['management_slug']}")
assert management.status_code == 200
assert '<link href="/admin.css?v=2" rel="stylesheet">' in management.text
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Copy complete FRITZ!Box fields" in management.text
assert "Copy CNAME record" in management.text
assert "Copy router fields" in management.text
assert "Copy CNAME" in management.text
assert "Manual updates" in management.text
assert "curl -sS" in management.text
assert "Public:" in management.text
@@ -174,7 +175,7 @@ def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> N
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
)
assert bad_unlock.status_code == 401
assert "Incorrect dashboard password." in bad_unlock.text
assert "Incorrect password." in bad_unlock.text
assert "Update-URL:" not in bad_unlock.text
good_unlock = locked_client.post(
@@ -312,7 +313,7 @@ def test_existing_dashboard_password_requires_current_password_to_change_or_remo
assert change.status_code == 400
assert remove.status_code == 400
assert "Current dashboard password is incorrect." in change.text
assert "Current password is incorrect." in change.text
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
@@ -331,21 +332,21 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
dashboard = client.get(f"/m/{account['management_slug']}")
assert dashboard.status_code == 200
assert "Rotate Update-URL" in dashboard.text
assert "Rotate router credentials" in dashboard.text
assert "New update URL" in dashboard.text
assert "New password" in dashboard.text
assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text
rotated_update = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
assert rotated_update.status_code == 200
assert "Update-URL rotated." in rotated_update.text
assert "Update URL changed. Update your router now." in rotated_update.text
assert "data-secret-input" in rotated_update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
rotated_password = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(rotated_update.text), "action": "password"})
assert rotated_password.status_code == 200
assert "Router credentials rotated" in rotated_password.text
assert "One-box update URL:" in rotated_password.text
assert "Router password changed" in rotated_password.text
assert "Full update URL:" in rotated_password.text
assert "Kennwort (optional):" in rotated_password.text
assert "data-secret-toggle" in rotated_password.text
assert client.get(
@@ -434,7 +435,7 @@ def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypat
dashboard = client.get(f"/m/{account['management_slug']}")
challenge = client.post(f"/m/{account['management_slug']}/domains/request", data={"csrf": _csrf(dashboard.text), "domain": "example.net"})
assert challenge.status_code == 200
assert "Domain claim created" in challenge.text
assert "Claim created" in challenge.text
assert "TXT name" in challenge.text
assert "TXT value" in challenge.text
assert "Claim secret" in challenge.text
@@ -449,17 +450,17 @@ def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypat
)
assert verified.status_code == 200
assert "Domain verified" in verified.text
assert "Create custom hostname" in verified.text
assert "Create hostname" in verified.text
created = client.post(
f"/m/{account['management_slug']}/domains/hostnames",
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "Custom hostname created" in created.text
assert "Hostname created" in created.text
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
assert "Private status page:" in created.text
assert "Private dashboard:" in created.text
def test_dashboard_domain_claims_are_bound_to_creating_dashboard(tmp_path: Path, monkeypatch) -> None:
@@ -557,7 +558,7 @@ def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) ->
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
assert verify.status_code == 200
assert "Create credentials" in verify.text
assert "Create hostname" in verify.text
created = client.post(
"/accounts",
@@ -584,7 +585,7 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
assert 'href="/admin"' not in response.text
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "generates the hostname, secret update URL, and optional router compatibility credentials automatically" in response.text
assert "You get a hostname, a private update URL, and router fields if your router needs them." in response.text
assert "Linux cron updater" in response.text
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
@@ -617,27 +618,56 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
response = client.get("/")
assert response.status_code == 200
assert '<script src="/app.js" defer></script>' in response.text
assert '<link href="/app.css" rel="stylesheet">' in response.text
assert '<script src="/theme.js?v=2"></script>' in response.text
assert '<script src="/clipboard.js?v=2" defer></script>' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
assert '<link href="/app.css?v=2" rel="stylesheet">' in response.text
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
assert "<style>" not in response.text
assert "script-src 'self'" in response.headers["content-security-policy"]
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert "style-src 'self' https://cdn.jsdelivr.net" in response.headers["content-security-policy"]
assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"]
assert "style-src 'self'" in response.headers["content-security-policy"]
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert client.get("/app.js").status_code == 200
bootstrap_css = client.get("/bootstrap-subset.css")
assert bootstrap_css.status_code == 200
assert "text/css" in bootstrap_css.headers["content-type"]
assert bootstrap_css.headers["cache-control"] == "public, max-age=31536000, immutable"
assert client.get("/theme.js").status_code == 200
assert client.get("/clipboard.js").status_code == 200
assert client.get("/admin-tables.js").status_code == 200
app_css = client.get("/app.css")
assert app_css.status_code == 200
assert "text/css" in app_css.headers["content-type"]
assert app_css.headers["cache-control"] == "public, max-age=3600"
assert app_css.headers["cache-control"] == "public, max-age=31536000, immutable"
admin_css = client.get("/admin.css")
assert admin_css.status_code == 200
assert "text/css" in admin_css.headers["content-type"]
assert admin_css.headers["cache-control"] == "public, max-age=31536000, immutable"
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert '<link rel="canonical" href="https://karldns.de/">' in response.text
assert 'property="og:title" content="KarlDNS"' in response.text
assert 'property="og:url" content="https://karldns.de/"' in response.text
assert 'property="og:image" content="https://karldns.de/social-preview.png"' in response.text
assert 'property="og:image:width" content="1200"' in response.text
assert 'property="og:image:height" content="630"' in response.text
assert 'name="twitter:card" content="summary_large_image"' in response.text
assert 'application/ld+json' in response.text
assert "NetworkingApplication" in response.text
assert "Made by Karl" in response.text
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text
@@ -646,12 +676,41 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
privacy = client.get("/privacy")
assert privacy.status_code == 200
assert '<link rel="canonical" href="https://karldns.de/privacy">' in privacy.text
assert "Privacy and legal information for KarlDNS" in privacy.text
assert "WebPage" in privacy.text
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
assert "hashed router passwords" in privacy.text
assert "protected router passwords" in privacy.text
assert "Made by Karl" in privacy.text
def test_public_robots_and_sitemap_are_available(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_path="/ops",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
robots = client.get("/robots.txt")
sitemap = client.get("/sitemap.xml")
assert robots.status_code == 200
assert "User-agent: *" in robots.text
assert "Disallow: /ops/" in robots.text
assert "Disallow: /m/" in robots.text
assert "Disallow: /api/" in robots.text
assert "Sitemap: https://karldns.de/sitemap.xml" in robots.text
assert sitemap.status_code == 200
assert "application/xml" in sitemap.headers["content-type"]
assert "<loc>https://karldns.de/</loc>" in sitemap.text
assert "<loc>https://karldns.de/privacy</loc>" in sitemap.text
def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
@@ -666,11 +725,16 @@ def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
assert 'href="/site.webmanifest"' in response.text
for path in [
"/favicon.ico",
"/bootstrap-subset.css",
"/favicon-32x32.png",
"/favicon-16x16.png",
"/apple-touch-icon.png",
"/logo-nav.webp",
"/logo.webp",
"/logo-192.png",
"/logo-512.png",
"/social-preview.png",
"/social-preview.webp",
]:
asset = client.get(path)
assert asset.status_code == 200
+26 -1
View File
@@ -6,7 +6,7 @@ import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, make_app
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
@@ -145,6 +145,27 @@ def test_dns_publish_failure_does_not_update_record(tmp_path: Path, monkeypatch,
assert "dns down" not in caplog.text
def test_dns_reconciliation_failure_is_logged_separately(tmp_path: Path, monkeypatch, caplog) -> None:
def fail_publish(result, settings):
raise DnsReconciliationError("provider state mismatch")
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 500
assert store.list_records() == []
assert store.list_update_events()[0]["status"] == "dns_reconcile_failed"
assert store.get_service_metrics()["dns_reconcile_failures_total"] == 1
assert "DNS reconciliation failed for hostname=home.example.net" in caplog.text
def test_programming_error_during_publish_is_not_mapped_to_dns_failure(tmp_path: Path, monkeypatch) -> None:
def fail_publish(result, settings):
raise AssertionError("publish bug")
@@ -178,6 +199,10 @@ def test_dns_publish_success_updates_record(tmp_path: Path, monkeypatch) -> None
assert response.text == "good 203.0.113.9"
assert len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.9"
metrics = store.get_service_metrics()
assert metrics["update_requests_total"] == 1
assert metrics["update_success_total"] == 1
assert metrics["update_latency_samples_total"] == 1
def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Path, monkeypatch) -> None: