Polish KarlDNS production UI and docs
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
# Required for a public KarlDNS instance.
|
# Required for a public KarlDNS instance.
|
||||||
DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password
|
DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password
|
||||||
DDNS_ADMIN_PATH=/admin
|
DDNS_ADMIN_PATH=/admin
|
||||||
|
DDNS_DASHBOARD_COOKIE_SECRET=change-me-long-random-cookie-signing-secret
|
||||||
DDNS_PUBLIC_BASE_URL=https://karldns.de
|
DDNS_PUBLIC_BASE_URL=https://karldns.de
|
||||||
DDNS_HOSTNAME_SUFFIX=karldns.de
|
DDNS_HOSTNAME_SUFFIX=karldns.de
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ DDNS_TTL=60
|
|||||||
# DDNS_RATE_LIMIT_PER_MINUTE=60
|
# DDNS_RATE_LIMIT_PER_MINUTE=60
|
||||||
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
|
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
|
||||||
# DDNS_MAX_REQUEST_BODY_BYTES=16384
|
# DDNS_MAX_REQUEST_BODY_BYTES=16384
|
||||||
|
# DDNS_REDACTED_ACCESS_LOG=0
|
||||||
# DDNS_CLEANUP_CHALLENGE_HOURS=72
|
# DDNS_CLEANUP_CHALLENGE_HOURS=72
|
||||||
# DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
|
# DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
|
||||||
# DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320
|
# DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.playwright-cli/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.sqlite3-*
|
*.sqlite3-*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# KarlDNS
|
# 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.
|
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
|
## Screenshots
|
||||||
|
|
||||||
### Generate Router Credentials
|
### Create Router Credentials
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -29,6 +29,19 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## 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
|
## What It Does
|
||||||
|
|
||||||
KarlDNS lets you host your own managed DynDNS service:
|
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
|
```bash
|
||||||
export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value'
|
export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value'
|
||||||
export DDNS_ADMIN_PATH='/admin'
|
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_PUBLIC_BASE_URL='https://karldns.de'
|
||||||
export DDNS_HOSTNAME_SUFFIX='karldns.de'
|
export DDNS_HOSTNAME_SUFFIX='karldns.de'
|
||||||
export DDNS_DATABASE='/data/ddns.sqlite3'
|
export DDNS_DATABASE='/data/ddns.sqlite3'
|
||||||
@@ -146,7 +160,7 @@ KarlDNS also shows optional compatibility fields for routers that insist on sepa
|
|||||||
- `Benutzername` (optional)
|
- `Benutzername` (optional)
|
||||||
- `Kennwort` (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:
|
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`.
|
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:
|
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
|
## 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
|
```dns
|
||||||
home.example.com. CNAME a1b2c3d4.karldns.de.
|
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.
|
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
|
## Custom Domain Flow
|
||||||
|
|
||||||
@@ -325,7 +339,7 @@ cp .env.example .env
|
|||||||
docker compose up -d --build
|
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`.
|
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.
|
- Run behind HTTPS.
|
||||||
- Keep `.env` out of git.
|
- Keep `.env` out of git.
|
||||||
- Use a long random value for `DDNS_ADMIN_PASSWORD`.
|
- 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`.
|
- Change `DDNS_ADMIN_PATH` if you do not want the operator UI at `/admin`.
|
||||||
- Use a least-privilege DNS API token or TSIG key.
|
- 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`.
|
- 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.
|
- 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`.
|
- 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.
|
- 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:
|
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_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_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_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_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_CHALLENGE_HOURS` | `72` | Delete unverified DNS TXT claims after this many hours. |
|
||||||
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete generated hostnames that never received a router update after this many hours. |
|
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete generated hostnames that never received a router update after this many hours. |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
Before Width: | Height: | Size: 401 KiB After Width: | Height: | Size: 95 KiB |
@@ -11,7 +11,7 @@ from fastapi import FastAPI
|
|||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
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_models import DdnsSettings, UpdateResult
|
||||||
from .ddns_service import DdnsService
|
from .ddns_service import DdnsService
|
||||||
from .ddns_store import DdnsStore
|
from .ddns_store import DdnsStore
|
||||||
@@ -23,6 +23,7 @@ __all__ = [
|
|||||||
"DdnsStore",
|
"DdnsStore",
|
||||||
"DnsConfigurationError",
|
"DnsConfigurationError",
|
||||||
"DnsProviderError",
|
"DnsProviderError",
|
||||||
|
"DnsReconciliationError",
|
||||||
"UpdateResult",
|
"UpdateResult",
|
||||||
"delete_dns_records",
|
"delete_dns_records",
|
||||||
"make_app",
|
"make_app",
|
||||||
@@ -33,7 +34,7 @@ __all__ = [
|
|||||||
OPENAPI_TAGS = [
|
OPENAPI_TAGS = [
|
||||||
{
|
{
|
||||||
"name": "hostnames",
|
"name": "hostnames",
|
||||||
"description": "Create provider-owned or verified custom DynDNS hostnames.",
|
"description": "Create KarlDNS hostnames or hostnames under verified domains.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "domains",
|
"name": "domains",
|
||||||
@@ -45,7 +46,7 @@ OPENAPI_TAGS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "management",
|
"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:
|
except Exception as exc:
|
||||||
LOGGER.exception("Scheduled cleanup failed")
|
LOGGER.exception("Scheduled cleanup failed")
|
||||||
|
await run_in_threadpool(store.increment_metric, "cleanup_errors_total")
|
||||||
await run_in_threadpool(
|
await run_in_threadpool(
|
||||||
store.complete_scheduled_task,
|
store.complete_scheduled_task,
|
||||||
CLEANUP_TASK_NAME,
|
CLEANUP_TASK_NAME,
|
||||||
@@ -157,4 +159,5 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None:
|
|||||||
True,
|
True,
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
await run_in_threadpool(store.increment_metric, "cleanup_success_total")
|
||||||
await run_in_threadpool(store.optimize)
|
await run_in_threadpool(store.optimize)
|
||||||
@@ -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
|
||||||
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 477 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -9,18 +9,18 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(prog="karldns")
|
parser = argparse.ArgumentParser(prog="karldns")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
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 = subparsers.add_parser("render-fritz-url", help="Print a FRITZ!Box DynDNS update URL.")
|
||||||
render_url.add_argument("--base-url", required=True, help="Public base URL of this DDNS service.")
|
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("--host", required=True, help="DNS hostname configured in FRITZ!Box.")
|
||||||
render_url.add_argument("--include-ipv6", action="store_true")
|
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("--host", default="0.0.0.0")
|
||||||
serve.add_argument("--port", type=int, default=8080)
|
serve.add_argument("--port", type=int, default=8080)
|
||||||
serve.add_argument(
|
serve.add_argument(
|
||||||
"--access-log",
|
"--access-log",
|
||||||
action="store_true",
|
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()
|
args = parser.parse_args()
|
||||||
@@ -31,8 +31,10 @@ def main() -> None:
|
|||||||
|
|
||||||
settings = DdnsSettings.from_env()
|
settings = DdnsSettings.from_env()
|
||||||
if not settings.admin_password:
|
if not settings.admin_password:
|
||||||
raise SystemExit("DDNS_ADMIN_PASSWORD must be set.")
|
raise SystemExit("Set DDNS_ADMIN_PASSWORD before starting KarlDNS.")
|
||||||
uvicorn.run(make_app(settings), host=args.host, port=args.port, access_log=args.access_log)
|
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:
|
def _render_fritz_url(base_url: str, host: str, include_ipv6: bool) -> str:
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ def settings_from_env() -> DdnsSettings:
|
|||||||
"DDNS_DASHBOARD_STAY_UNLOCKED_SECONDS",
|
"DDNS_DASHBOARD_STAY_UNLOCKED_SECONDS",
|
||||||
30 * 24 * 60 * 60,
|
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),
|
ttl=_env_int("DDNS_TTL", 60),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .app import make_app
|
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_models import DdnsSettings, UpdateResult
|
||||||
from .ddns_store import DdnsStore
|
from .ddns_store import DdnsStore
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ __all__ = [
|
|||||||
"DdnsStore",
|
"DdnsStore",
|
||||||
"DnsConfigurationError",
|
"DnsConfigurationError",
|
||||||
"DnsProviderError",
|
"DnsProviderError",
|
||||||
|
"DnsReconciliationError",
|
||||||
"UpdateResult",
|
"UpdateResult",
|
||||||
"delete_dns_records",
|
"delete_dns_records",
|
||||||
"make_app",
|
"make_app",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from .dns import (
|
from .dns import (
|
||||||
DnsConfigurationError,
|
DnsConfigurationError,
|
||||||
DnsProviderError,
|
DnsProviderError,
|
||||||
|
DnsReconciliationError,
|
||||||
delete_cloudflare_records,
|
delete_cloudflare_records,
|
||||||
delete_dns_records,
|
delete_dns_records,
|
||||||
delete_rfc2136_records,
|
delete_rfc2136_records,
|
||||||
@@ -16,6 +17,7 @@ from .dns import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"DnsConfigurationError",
|
"DnsConfigurationError",
|
||||||
"DnsProviderError",
|
"DnsProviderError",
|
||||||
|
"DnsReconciliationError",
|
||||||
"delete_cloudflare_records",
|
"delete_cloudflare_records",
|
||||||
"delete_dns_records",
|
"delete_dns_records",
|
||||||
"delete_rfc2136_records",
|
"delete_rfc2136_records",
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import urllib.parse
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class DdnsSettings(BaseModel):
|
class DdnsSettings(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
database_path: Path = Field(default=Path("ddns.sqlite3"))
|
database_path: Path = Field(default=Path("ddns.sqlite3"))
|
||||||
shared_secret: str = Field(default="")
|
shared_secret: str = Field(default="")
|
||||||
admin_password: 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)
|
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_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_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)
|
ttl: int = Field(default=60, ge=60, le=86400)
|
||||||
|
|
||||||
@field_validator("public_base_url")
|
@field_validator("public_base_url")
|
||||||
@@ -61,16 +65,22 @@ class DdnsSettings(BaseModel):
|
|||||||
raise ValueError("admin_path may only contain URL-safe path characters")
|
raise ValueError("admin_path may only contain URL-safe path characters")
|
||||||
reserved_paths = {
|
reserved_paths = {
|
||||||
"/accounts",
|
"/accounts",
|
||||||
|
"/admin.css",
|
||||||
|
"/admin-tables.js",
|
||||||
"/api",
|
"/api",
|
||||||
"/app.js",
|
"/app.js",
|
||||||
"/claim",
|
"/claim",
|
||||||
|
"/clipboard.js",
|
||||||
"/d",
|
"/d",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
"/healthz",
|
"/healthz",
|
||||||
"/install.sh",
|
"/install.sh",
|
||||||
"/ip",
|
"/ip",
|
||||||
|
"/bootstrap-subset.css",
|
||||||
"/logo.png",
|
"/logo.png",
|
||||||
|
"/logo-nav.webp",
|
||||||
|
"/logo.webp",
|
||||||
"/m",
|
"/m",
|
||||||
"/magic",
|
"/magic",
|
||||||
"/nic",
|
"/nic",
|
||||||
@@ -78,7 +88,11 @@ class DdnsSettings(BaseModel):
|
|||||||
"/privacy",
|
"/privacy",
|
||||||
"/redoc",
|
"/redoc",
|
||||||
"/robots.txt",
|
"/robots.txt",
|
||||||
|
"/sitemap.xml",
|
||||||
"/site.webmanifest",
|
"/site.webmanifest",
|
||||||
|
"/social-preview.png",
|
||||||
|
"/social-preview.webp",
|
||||||
|
"/theme.js",
|
||||||
"/u",
|
"/u",
|
||||||
"/uninstall.sh",
|
"/uninstall.sh",
|
||||||
}
|
}
|
||||||
@@ -104,6 +118,10 @@ class DdnsSettings(BaseModel):
|
|||||||
return
|
return
|
||||||
if len(self.admin_password) < 16:
|
if len(self.admin_password) < 16:
|
||||||
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
|
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:
|
if not self.hostname_suffix:
|
||||||
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
|
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
|
||||||
if not self.effective_trusted_hosts:
|
if not self.effective_trusted_hosts:
|
||||||
|
|||||||
@@ -53,17 +53,31 @@ def sign_dashboard_unlock_cookie(
|
|||||||
hostname: str,
|
hostname: str,
|
||||||
management_slug: str,
|
management_slug: str,
|
||||||
password_hash: str,
|
password_hash: str,
|
||||||
|
signing_secret: str,
|
||||||
max_age_seconds: int,
|
max_age_seconds: int,
|
||||||
now: int | None = None,
|
now: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
timestamp = int(time.time() if now is None else now)
|
timestamp = int(time.time() if now is None else now)
|
||||||
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
|
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
|
||||||
signature = _dashboard_unlock_signature(hostname, management_slug, password_hash, timestamp, bounded_max_age)
|
signature = _dashboard_unlock_signature(
|
||||||
return f"v2:{timestamp}:{bounded_max_age}:{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:
|
def verify_dashboard_unlock_cookie(
|
||||||
if not password_hash or not cookie_value:
|
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
|
return False
|
||||||
try:
|
try:
|
||||||
version, raw_timestamp, raw_max_age, supplied = cookie_value.split(":", 3)
|
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))
|
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
if version != "v2":
|
if version != "v3":
|
||||||
return False
|
return False
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
if timestamp > now + 60 or now - timestamp > max_age_seconds:
|
if timestamp > now + 60 or now - timestamp > max_age_seconds:
|
||||||
return False
|
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)
|
return hmac.compare_digest(supplied, expected)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,11 +105,16 @@ def _dashboard_unlock_signature(
|
|||||||
hostname: str,
|
hostname: str,
|
||||||
management_slug: str,
|
management_slug: str,
|
||||||
password_hash: str,
|
password_hash: str,
|
||||||
|
signing_secret: str,
|
||||||
timestamp: int,
|
timestamp: int,
|
||||||
max_age_seconds: int,
|
max_age_seconds: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
payload = f"{hostname}\0{management_slug}\0{timestamp}\0{max_age_seconds}".encode()
|
payload = f"{hostname}\0{management_slug}\0{password_hash}\0{timestamp}\0{max_age_seconds}".encode()
|
||||||
return hmac.new(password_hash.encode(), payload, hashlib.sha256).hexdigest()
|
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:
|
def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | None]) -> str:
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
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_models import DdnsSettings, UpdateResult
|
||||||
from .ddns_security import client_ip
|
from .ddns_security import client_ip
|
||||||
from .ddns_store import DdnsStore
|
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__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -121,7 +122,13 @@ class DdnsService:
|
|||||||
myipv6: str | None,
|
myipv6: str | None,
|
||||||
ip6addr: str | None,
|
ip6addr: str | None,
|
||||||
) -> UpdateOutcome:
|
) -> 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)
|
source_ip = client_ip(request, self.settings)
|
||||||
|
try:
|
||||||
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
|
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
|
||||||
source_ip,
|
source_ip,
|
||||||
myip,
|
myip,
|
||||||
@@ -135,7 +142,26 @@ class DdnsService:
|
|||||||
if planned.changed:
|
if planned.changed:
|
||||||
try:
|
try:
|
||||||
publish_dns(planned, self.settings)
|
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:
|
except DnsProviderError as exc:
|
||||||
|
is_dns_failure = True
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"DNS publish failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
|
"DNS publish failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
|
||||||
hostname,
|
hostname,
|
||||||
@@ -146,11 +172,25 @@ class DdnsService:
|
|||||||
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
|
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
|
||||||
raise
|
raise
|
||||||
result = self.store.commit_update_result(planned)
|
result = self.store.commit_update_result(planned)
|
||||||
|
is_success = True
|
||||||
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "ok", source_ip)
|
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "ok", source_ip)
|
||||||
else:
|
else:
|
||||||
result = planned
|
result = planned
|
||||||
|
is_success = True
|
||||||
self.store.log_update_rollup(hostname, result.ipv4, result.ipv6, UNCHANGED, "ok", source_ip)
|
self.store.log_update_rollup(hostname, result.ipv4, result.ipv6, UNCHANGED, "ok", source_ip)
|
||||||
return UpdateOutcome(result=result, source_ip=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:
|
def fritz_update_url(self, account: dict[str, str]) -> str:
|
||||||
base = self.settings.public_base_url.rstrip("/")
|
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(".")
|
suffix = settings.hostname_suffix.strip().lower().strip(".")
|
||||||
if expand_suffix and suffix and "." not in hostname:
|
if expand_suffix and suffix and "." not in hostname:
|
||||||
hostname = f"{hostname}.{suffix}"
|
hostname = f"{hostname}.{suffix}"
|
||||||
label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
if not _valid_domain_name(hostname):
|
||||||
if not re.fullmatch(rf"{label}(\.{label})+", hostname):
|
|
||||||
return ""
|
return ""
|
||||||
return hostname
|
return hostname
|
||||||
|
|
||||||
|
|
||||||
def normalize_domain(value: str) -> str:
|
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:
|
def dns_txt_contains(name: str, expected: str) -> bool:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from .base import (
|
from .base import (
|
||||||
DnsConfigurationError,
|
DnsConfigurationError,
|
||||||
DnsProviderError,
|
DnsProviderError,
|
||||||
|
DnsReconciliationError,
|
||||||
delete_dns_records,
|
delete_dns_records,
|
||||||
dns_backend_configured,
|
dns_backend_configured,
|
||||||
publish_dns,
|
publish_dns,
|
||||||
@@ -14,6 +15,7 @@ from .rfc2136 import delete_rfc2136_records, publish_rfc2136
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"DnsConfigurationError",
|
"DnsConfigurationError",
|
||||||
"DnsProviderError",
|
"DnsProviderError",
|
||||||
|
"DnsReconciliationError",
|
||||||
"delete_cloudflare_records",
|
"delete_cloudflare_records",
|
||||||
"delete_dns_records",
|
"delete_dns_records",
|
||||||
"delete_rfc2136_records",
|
"delete_rfc2136_records",
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ class DnsProviderError(RuntimeError):
|
|||||||
"""Raised for expected DNS provider publish/delete failures."""
|
"""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):
|
class DnsConfigurationError(RuntimeError):
|
||||||
"""Raised when DNS provider settings are invalid at startup."""
|
"""Raised when DNS provider settings are invalid at startup."""
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from ..ddns_models import DdnsSettings, UpdateResult
|
from ..ddns_models import DdnsSettings, UpdateResult
|
||||||
from .base import DnsProviderError
|
from .base import DnsProviderError, DnsReconciliationError
|
||||||
|
|
||||||
|
|
||||||
def publish_cloudflare(result: UpdateResult, settings: DdnsSettings) -> None:
|
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)
|
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="PATCH", payload=payload)
|
||||||
else:
|
else:
|
||||||
_cloudflare_request(settings, "/dns_records", method="POST", payload=payload)
|
_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:
|
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
|
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(
|
def _cloudflare_request(
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
DNS_DELETE_FAILED = "dns_delete_failed"
|
DNS_DELETE_FAILED = "dns_delete_failed"
|
||||||
DNS_FAILED = "dns_failed"
|
DNS_FAILED = "dns_failed"
|
||||||
|
DNS_RECONCILE_FAILED = "dns_reconcile_failed"
|
||||||
UPDATED = "updated"
|
UPDATED = "updated"
|
||||||
UNCHANGED = "unchanged"
|
UNCHANGED = "unchanged"
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ..web.templates import _copy_row
|
|||||||
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
|
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
|
||||||
|
|
||||||
ADMIN_TABLE_LIMIT = 100
|
ADMIN_TABLE_LIMIT = 100
|
||||||
|
ADMIN_JSON_OFFSET_CAP = 10_000
|
||||||
|
|
||||||
|
|
||||||
def make_router(admin_path: str = "/admin") -> APIRouter:
|
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_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
|
||||||
store.list_scheduled_tasks(),
|
store.list_scheduled_tasks(),
|
||||||
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
|
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
|
||||||
|
store.get_service_metrics(),
|
||||||
{
|
{
|
||||||
"accounts": store.count_accounts(),
|
"accounts": store.count_accounts(),
|
||||||
"domain_challenges": store.count_domain_challenges(),
|
"domain_challenges": store.count_domain_challenges(),
|
||||||
@@ -131,7 +133,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
"router update URL rotated by admin",
|
"router update URL rotated by admin",
|
||||||
client_ip(request, settings),
|
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":
|
elif action == "management":
|
||||||
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
|
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
|
||||||
await run_in_threadpool(
|
await run_in_threadpool(
|
||||||
@@ -143,7 +145,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
"management URL rotated by admin",
|
"management URL rotated by admin",
|
||||||
client_ip(request, settings),
|
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":
|
elif action == "password":
|
||||||
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
||||||
await run_in_threadpool(
|
await run_in_threadpool(
|
||||||
@@ -157,10 +159,10 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
)
|
)
|
||||||
if rotated:
|
if rotated:
|
||||||
notice = _admin_secret_panel(
|
notice = _admin_secret_panel(
|
||||||
"Router password rotated",
|
"Router password changed",
|
||||||
[
|
[
|
||||||
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
|
_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("Benutzername (optional):", str(rotated["username"]), "Public"),
|
||||||
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
|
_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,
|
settings.cleanup_event_history_hours,
|
||||||
)
|
)
|
||||||
notice = _admin_secret_panel(
|
notice = _admin_secret_panel(
|
||||||
"Cleanup completed",
|
"Cleanup done",
|
||||||
[
|
[
|
||||||
_copy_row("Domain claims removed", str(result["domain_challenges"])),
|
_copy_row("Domain claims removed", str(result["domain_challenges"])),
|
||||||
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
|
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
|
||||||
@@ -202,7 +204,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: None = Depends(authenticate_admin),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
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]:
|
) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"items": store.list_records_page(limit=limit, offset=offset),
|
"items": store.list_records_page(limit=limit, offset=offset),
|
||||||
@@ -216,12 +218,16 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: None = Depends(authenticate_admin),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
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]:
|
) -> 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 {
|
return {
|
||||||
"items": store.list_update_events_page(limit=limit, offset=offset),
|
"items": items,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
|
"next_before": next_before,
|
||||||
"total": store.count_update_events(),
|
"total": store.count_update_events(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from ..schemas import (
|
|||||||
_credentials_response,
|
_credentials_response,
|
||||||
_managed_record,
|
_managed_record,
|
||||||
)
|
)
|
||||||
|
from .path_params import ManagementSlugPath, UpdateSlugPath
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ router = APIRouter(prefix="/api/v1")
|
|||||||
"/hostnames/magic",
|
"/hostnames/magic",
|
||||||
response_model=HostnameCredentials,
|
response_model=HostnameCredentials,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Generate a provider-owned DynDNS hostname",
|
summary="Create a KarlDNS hostname",
|
||||||
tags=["hostnames"],
|
tags=["hostnames"],
|
||||||
responses={500: {"model": ErrorResponse}},
|
responses={500: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
@@ -42,12 +43,12 @@ async def create_magic_hostname(payload: MagicHostnameRequest, service: ServiceD
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/management/{management_slug}",
|
"/management/{management_slug}",
|
||||||
response_model=ManagedRecord,
|
response_model=ManagedRecord,
|
||||||
summary="Read a hostname by magic management link",
|
summary="Read a hostname by private dashboard link",
|
||||||
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
|
description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
|
||||||
tags=["management"],
|
tags=["management"],
|
||||||
responses={404: {"model": ErrorResponse}},
|
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)
|
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
|
||||||
if not account or account["disabled"]:
|
if not account or account["disabled"]:
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
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(
|
@router.delete(
|
||||||
"/management/{management_slug}",
|
"/management/{management_slug}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Delete a hostname by magic management link",
|
summary="Delete a hostname by private dashboard link",
|
||||||
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
|
description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
|
||||||
tags=["management"],
|
tags=["management"],
|
||||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
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)
|
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
|
||||||
if not account or account["disabled"]:
|
if not account or account["disabled"]:
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
@@ -70,7 +71,7 @@ async def delete_managed_hostname(management_slug: str, store: StoreDep, service
|
|||||||
try:
|
try:
|
||||||
await run_in_threadpool(service.delete_account, hostname, "api")
|
await run_in_threadpool(service.delete_account, hostname, "api")
|
||||||
except DnsProviderError as exc:
|
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)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> Ve
|
|||||||
return VerifyDomainResponse(
|
return VerifyDomainResponse(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
verified=True,
|
verified=True,
|
||||||
message="domain verified",
|
message="Domain verified",
|
||||||
claim_secret=payload.claim_secret,
|
claim_secret=payload.claim_secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> Ve
|
|||||||
"/hostnames/custom",
|
"/hostnames/custom",
|
||||||
response_model=HostnameCredentials,
|
response_model=HostnameCredentials,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Generate credentials for a verified custom hostname",
|
summary="Create a hostname for a verified domain",
|
||||||
tags=["hostnames"],
|
tags=["hostnames"],
|
||||||
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
|
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}},
|
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
async def update_hostname(
|
async def update_hostname(
|
||||||
update_slug: str,
|
update_slug: UpdateSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
@@ -174,6 +175,6 @@ async def update_hostname(
|
|||||||
except BadIpError as exc:
|
except BadIpError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except DnsProviderError as 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
|
result = outcome.result
|
||||||
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
|
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
|
||||||
@@ -5,13 +5,13 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, Response
|
from fastapi import APIRouter, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
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"
|
ASSET_DIR = Path(__file__).parents[1] / "assets"
|
||||||
router = APIRouter(include_in_schema=False)
|
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 media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const storageKey = "karldns-theme";
|
const storageKey = "karldns-theme";
|
||||||
@@ -30,8 +30,9 @@ APP_JS = """
|
|||||||
};
|
};
|
||||||
const syncToggle = (theme) => {
|
const syncToggle = (theme) => {
|
||||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||||
button.textContent = theme === "dark" ? "Dark" : "Light";
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||||
button.setAttribute("aria-label", `Switch to ${theme === "dark" ? "light" : "dark"} theme`);
|
button.dataset.themeNext = nextTheme;
|
||||||
|
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
|
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
|
||||||
@@ -54,7 +55,10 @@ APP_JS = """
|
|||||||
applyTheme(nextTheme);
|
applyTheme(nextTheme);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
CLIPBOARD_JS = """
|
||||||
document.addEventListener("click", async (event) => {
|
document.addEventListener("click", async (event) => {
|
||||||
const toggle = event.target.closest("[data-secret-toggle]");
|
const toggle = event.target.closest("[data-secret-toggle]");
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
@@ -87,7 +91,10 @@ document.addEventListener("click", async (event) => {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_TABLES_JS = """
|
||||||
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
||||||
const table = tableShell.querySelector("table");
|
const table = tableShell.querySelector("table");
|
||||||
const tbody = tableShell.querySelector("tbody");
|
const tbody = tableShell.querySelector("tbody");
|
||||||
@@ -176,10 +183,22 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
|||||||
""".strip()
|
""".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")
|
@router.get("/favicon.ico")
|
||||||
def favicon() -> FileResponse:
|
def favicon() -> FileResponse:
|
||||||
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
|
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")
|
@router.get("/favicon-16x16.png")
|
||||||
def favicon_16_png() -> FileResponse:
|
def favicon_16_png() -> FileResponse:
|
||||||
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
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:
|
def logo_png() -> FileResponse:
|
||||||
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
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")
|
@router.get("/logo-192.png")
|
||||||
def logo_192_png() -> FileResponse:
|
def logo_192_png() -> FileResponse:
|
||||||
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
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:
|
def logo_512_png() -> FileResponse:
|
||||||
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
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")
|
@router.get("/site.webmanifest")
|
||||||
def site_webmanifest() -> Response:
|
def site_webmanifest() -> Response:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -211,7 +246,7 @@ def site_webmanifest() -> Response:
|
|||||||
{
|
{
|
||||||
"name": "KarlDNS",
|
"name": "KarlDNS",
|
||||||
"short_name": "KarlDNS",
|
"short_name": "KarlDNS",
|
||||||
"description": "Accountless Dynamic DNS for routers and home labs.",
|
"description": "Private update links for routers.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
@@ -227,14 +262,29 @@ def site_webmanifest() -> Response:
|
|||||||
headers=ASSET_CACHE_HEADERS,
|
headers=ASSET_CACHE_HEADERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/app.js")
|
@router.get("/theme.js")
|
||||||
def app_js() -> Response:
|
def theme_js() -> Response:
|
||||||
return Response(APP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
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")
|
@router.get("/app.css")
|
||||||
def app_css() -> Response:
|
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")
|
@router.get("/install.sh")
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from .management_shared import (
|
|||||||
locked_management_response,
|
locked_management_response,
|
||||||
render_management_page_async,
|
render_management_page_async,
|
||||||
)
|
)
|
||||||
|
from .path_params import ManagementSlugPath
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(management_passwords.router)
|
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)
|
@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)
|
account = get_active_management_account(store, management_slug)
|
||||||
csrf_token = dashboard_csrf_token(management_slug, account)
|
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_unlock_page(management_slug, account, csrf_token)
|
||||||
return _render_management_page(service, settings, 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)
|
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def rotate_magic_credentials(
|
async def rotate_magic_credentials(
|
||||||
management_slug: str,
|
management_slug: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
) -> str:
|
) -> str:
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
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)
|
return locked_management_response(management_slug, account)
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||||
@@ -75,7 +76,7 @@ async def rotate_magic_credentials(
|
|||||||
management_slug,
|
management_slug,
|
||||||
rotated,
|
rotated,
|
||||||
dashboard_csrf_token(management_slug, rotated),
|
dashboard_csrf_token(management_slug, rotated),
|
||||||
message="Update-URL rotated.",
|
message="Update URL changed. Update your router now.",
|
||||||
)
|
)
|
||||||
if action == "password":
|
if action == "password":
|
||||||
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
||||||
@@ -97,16 +98,16 @@ async def rotate_magic_credentials(
|
|||||||
management_slug,
|
management_slug,
|
||||||
rotated,
|
rotated,
|
||||||
dashboard_csrf_token(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,
|
created_account=rotated,
|
||||||
)
|
)
|
||||||
raise _bad_rotation_action()
|
raise _bad_rotation_action()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m/{management_slug}/delete", include_in_schema=False)
|
@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)
|
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)
|
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
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:
|
try:
|
||||||
await run_in_threadpool(service.delete_account, hostname, "management-link")
|
await run_in_threadpool(service.delete_account, hostname, "management-link")
|
||||||
except DnsProviderError:
|
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(
|
await run_in_threadpool(
|
||||||
store.log_update_event,
|
store.log_update_event,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -126,7 +127,7 @@ async def delete_magic(management_slug: str, request: Request, settings: Setting
|
|||||||
)
|
)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_page(
|
_page(
|
||||||
"DynDNS deleted",
|
"Hostname deleted",
|
||||||
trusted(
|
trusted(
|
||||||
"""
|
"""
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ from .management_shared import (
|
|||||||
locked_management_response,
|
locked_management_response,
|
||||||
render_management_page_async,
|
render_management_page_async,
|
||||||
)
|
)
|
||||||
|
from .path_params import ClaimSecretPath, ManagementSlugPath
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/d/{claim_secret}", response_class=HTMLResponse, include_in_schema=False)
|
@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)
|
challenge = await run_in_threadpool(store.get_domain_challenge_by_secret, claim_secret)
|
||||||
if not challenge:
|
if not challenge:
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
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)
|
@router.post("/m/{management_slug}/domains/request", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def request_dashboard_domain(
|
async def request_dashboard_domain(
|
||||||
management_slug: str,
|
management_slug: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
) -> str:
|
) -> str:
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
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)
|
return locked_management_response(management_slug, account)
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||||
@@ -71,28 +72,28 @@ async def request_dashboard_domain(
|
|||||||
management_slug,
|
management_slug,
|
||||||
account,
|
account,
|
||||||
dashboard_csrf_token(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,
|
challenge=challenge,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m/{management_slug}/domains/verify", response_class=HTMLResponse, include_in_schema=False)
|
@router.post("/m/{management_slug}/domains/verify", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def verify_dashboard_domain(
|
async def verify_dashboard_domain(
|
||||||
management_slug: str,
|
management_slug: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
) -> str:
|
) -> str:
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
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)
|
return locked_management_response(management_slug, account)
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||||
domain = normalize_domain(first_form_value(form, "domain"))
|
domain = normalize_domain(first_form_value(form, "domain"))
|
||||||
claim_secret = first_form_value(form, "claim_secret")
|
claim_secret = first_form_value(form, "claim_secret")
|
||||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, str(account["hostname"]))
|
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(
|
await run_in_threadpool(
|
||||||
store.log_update_event,
|
store.log_update_event,
|
||||||
str(account["hostname"]),
|
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)
|
@router.post("/m/{management_slug}/domains/hostnames", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def create_dashboard_custom_hostname(
|
async def create_dashboard_custom_hostname(
|
||||||
management_slug: str,
|
management_slug: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
) -> str:
|
) -> str:
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
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)
|
return locked_management_response(management_slug, account)
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||||
@@ -148,6 +149,6 @@ async def create_dashboard_custom_hostname(
|
|||||||
management_slug,
|
management_slug,
|
||||||
account,
|
account,
|
||||||
dashboard_csrf_token(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,
|
created_account=created,
|
||||||
)
|
)
|
||||||
@@ -18,12 +18,13 @@ from .management_shared import (
|
|||||||
set_unlock_cookie,
|
set_unlock_cookie,
|
||||||
unlock_cookie_seconds,
|
unlock_cookie_seconds,
|
||||||
)
|
)
|
||||||
|
from .path_params import ManagementSlugPath
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
|
@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)
|
account = await get_active_management_account_async(store, management_slug)
|
||||||
if not account.get("dashboard_password_hash"):
|
if not account.get("dashboard_password_hash"):
|
||||||
return RedirectResponse(f"/m/{management_slug}", status_code=303)
|
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)
|
is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return HTMLResponse(
|
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,
|
status_code=401,
|
||||||
)
|
)
|
||||||
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
|
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)
|
@router.post("/m/{management_slug}/password", include_in_schema=False)
|
||||||
async def set_dashboard_password(
|
async def set_dashboard_password(
|
||||||
management_slug: str,
|
management_slug: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
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)
|
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
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"),
|
first_form_value(form, "current_dashboard_password"),
|
||||||
)
|
)
|
||||||
if not is_current:
|
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")
|
password = first_form_value(form, "dashboard_password")
|
||||||
confirm = first_form_value(form, "dashboard_password_confirm")
|
confirm = first_form_value(form, "dashboard_password_confirm")
|
||||||
if len(password) < 8:
|
if len(password) < 8:
|
||||||
@@ -79,7 +80,7 @@ async def set_dashboard_password(
|
|||||||
management_slug,
|
management_slug,
|
||||||
account,
|
account,
|
||||||
dashboard_csrf_token(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)
|
return HTMLResponse(page, status_code=400)
|
||||||
if password != confirm:
|
if password != confirm:
|
||||||
@@ -89,7 +90,7 @@ async def set_dashboard_password(
|
|||||||
management_slug,
|
management_slug,
|
||||||
account,
|
account,
|
||||||
dashboard_csrf_token(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)
|
return HTMLResponse(page, status_code=400)
|
||||||
had_password = bool(account.get("dashboard_password_hash"))
|
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)
|
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
|
||||||
async def remove_dashboard_password(
|
async def remove_dashboard_password(
|
||||||
management_slug: str,
|
management_slug: ManagementSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
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)
|
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
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"),
|
first_form_value(form, "current_dashboard_password"),
|
||||||
)
|
)
|
||||||
if not is_current:
|
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.clear_dashboard_password, hostname)
|
||||||
await run_in_threadpool(
|
await run_in_threadpool(
|
||||||
store.log_update_event,
|
store.log_update_event,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from starlette.concurrency import run_in_threadpool
|
|||||||
from ..ddns_models import DdnsSettings
|
from ..ddns_models import DdnsSettings
|
||||||
from ..ddns_security import (
|
from ..ddns_security import (
|
||||||
DASHBOARD_UNLOCK_COOKIE,
|
DASHBOARD_UNLOCK_COOKIE,
|
||||||
|
dashboard_cookie_signing_secret,
|
||||||
dashboard_csrf_token,
|
dashboard_csrf_token,
|
||||||
sign_dashboard_unlock_cookie,
|
sign_dashboard_unlock_cookie,
|
||||||
verify_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)
|
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")
|
password_hash = account.get("dashboard_password_hash")
|
||||||
if not password_hash:
|
if not password_hash:
|
||||||
return True
|
return True
|
||||||
@@ -38,6 +39,7 @@ def has_management_access(request: Request, management_slug: str, account: Manag
|
|||||||
str(account["hostname"]),
|
str(account["hostname"]),
|
||||||
management_slug,
|
management_slug,
|
||||||
str(password_hash),
|
str(password_hash),
|
||||||
|
dashboard_cookie_signing_secret(settings),
|
||||||
request.cookies.get(DASHBOARD_UNLOCK_COOKIE),
|
request.cookies.get(DASHBOARD_UNLOCK_COOKIE),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,11 +102,18 @@ def set_unlock_cookie(
|
|||||||
max_age_seconds: int,
|
max_age_seconds: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
password_hash = account.get("dashboard_password_hash")
|
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
|
return
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
DASHBOARD_UNLOCK_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,
|
max_age=max_age_seconds,
|
||||||
path=f"/m/{management_slug}",
|
path=f"/m/{management_slug}",
|
||||||
httponly=True,
|
httponly=True,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
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 starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from ..ddns_security import client_ip
|
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")
|
claim_secret = first_form_value(form, "claim_secret")
|
||||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
|
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
|
||||||
if not found:
|
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, 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)
|
@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)
|
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
|
||||||
def privacy() -> str:
|
def privacy(settings: SettingsDep) -> str:
|
||||||
return _render_privacy_page()
|
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")
|
||||||
@@ -6,13 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update
|
from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update
|
||||||
|
from .path_params import UpdateSlugPath
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
|
@router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
|
||||||
def slug_update(
|
def slug_update(
|
||||||
update_slug: str,
|
update_slug: UpdateSlugPath,
|
||||||
request: Request,
|
request: Request,
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ class HostnameCredentials(BaseModel):
|
|||||||
hostname: str
|
hostname: str
|
||||||
username: str
|
username: str
|
||||||
password: str = Field(description="Secret router password. Store it now; it is not recoverable later.")
|
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(
|
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):
|
class ManagedRecord(BaseModel):
|
||||||
hostname: str
|
hostname: str
|
||||||
username: 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
|
ipv4: str | None = None
|
||||||
ipv6: str | None = None
|
ipv6: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
@@ -43,7 +43,7 @@ class DomainChallengeResponse(BaseModel):
|
|||||||
domain: str
|
domain: str
|
||||||
txt_name: str
|
txt_name: str
|
||||||
txt_value: 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):
|
class VerifyDomainRequest(BaseModel):
|
||||||
@@ -57,7 +57,7 @@ class VerifyDomainResponse(BaseModel):
|
|||||||
domain: str
|
domain: str
|
||||||
verified: bool
|
verified: bool
|
||||||
message: str
|
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):
|
class CustomHostnameRequest(BaseModel):
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ from time import monotonic
|
|||||||
|
|
||||||
from .accounts import AccountsMixin
|
from .accounts import AccountsMixin
|
||||||
from .domains import DomainsMixin
|
from .domains import DomainsMixin
|
||||||
|
from .metrics import MetricsMixin
|
||||||
from .rate_limit import RateLimitCleanupMixin
|
from .rate_limit import RateLimitCleanupMixin
|
||||||
from .records import RecordsEventsMixin
|
from .records import RecordsEventsMixin
|
||||||
from .scheduler import SchedulerMixin
|
from .scheduler import SchedulerMixin
|
||||||
from .schema import connect_database, initialize_schema
|
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:
|
def __init__(self, database_path: Path) -> None:
|
||||||
self.database_path = database_path
|
self.database_path = database_path
|
||||||
self._connections = threading.local()
|
self._connections = threading.local()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from ..ddns_security import hash_lookup_token, hash_secret, verify_secret
|
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]:
|
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT accounts.hostname, accounts.username, accounts.update_slug,
|
SELECT accounts.hostname, accounts.username, accounts.update_slug,
|
||||||
@@ -57,7 +55,6 @@ class AccountsMixin:
|
|||||||
|
|
||||||
def get_account_by_hostname(self, hostname: str) -> AccountRow | None:
|
def get_account_by_hostname(self, hostname: str) -> AccountRow | None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
account = conn.execute(
|
account = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
|
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:
|
def get_account_by_slug(self, update_slug: str) -> AccountRow | None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
account = conn.execute(
|
account = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
|
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:
|
def get_account_by_management_slug(self, management_slug: str) -> AccountRow | None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
account = conn.execute(
|
account = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
|
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
|
||||||
@@ -101,7 +96,6 @@ class AccountsMixin:
|
|||||||
if not username or not token:
|
if not username or not token:
|
||||||
return False
|
return False
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
account = conn.execute(
|
account = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT token_hash, disabled FROM accounts
|
SELECT token_hash, disabled FROM accounts
|
||||||
@@ -162,7 +156,6 @@ class AccountsMixin:
|
|||||||
if not password:
|
if not password:
|
||||||
return False
|
return False
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
account = conn.execute(
|
account = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT dashboard_password_hash, disabled FROM accounts
|
SELECT dashboard_password_hash, disabled FROM accounts
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ class DomainsMixin:
|
|||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
claim_hash = hash_lookup_token(claim_secret)
|
claim_hash = hash_lookup_token(claim_secret)
|
||||||
with self.connect() as conn:
|
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 = ?"
|
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ? AND claim_hash = ?"
|
||||||
params = [domain, claim_hash]
|
params = [domain, claim_hash]
|
||||||
if owner_hostname is not None:
|
if owner_hostname is not None:
|
||||||
@@ -76,7 +75,6 @@ class DomainsMixin:
|
|||||||
owner_hostname: str | None = None,
|
owner_hostname: str | None = None,
|
||||||
) -> DomainChallengeRow | None:
|
) -> DomainChallengeRow | None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ?"
|
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ?"
|
||||||
params = [domain]
|
params = [domain]
|
||||||
if claim_secret is not None:
|
if claim_secret is not None:
|
||||||
@@ -90,7 +88,6 @@ class DomainsMixin:
|
|||||||
|
|
||||||
def get_domain_challenge_by_secret(self, claim_secret: str) -> DomainChallengeRow | None:
|
def get_domain_challenge_by_secret(self, claim_secret: str) -> DomainChallengeRow | None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
challenge = conn.execute(
|
challenge = conn.execute(
|
||||||
"SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE claim_hash = ?",
|
"SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE claim_hash = ?",
|
||||||
(hash_lookup_token(claim_secret),),
|
(hash_lookup_token(claim_secret),),
|
||||||
@@ -103,7 +100,6 @@ class DomainsMixin:
|
|||||||
|
|
||||||
def list_domain_challenges(self, limit: int = 100, offset: int = 0) -> list[DomainChallengeRow]:
|
def list_domain_challenges(self, limit: int = 100, offset: int = 0) -> list[DomainChallengeRow]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT domain, owner_hostname, created_at, verified_at
|
SELECT domain, owner_hostname, created_at, verified_at
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
@@ -12,7 +11,6 @@ RATE_LIMIT_FLUSH_SECONDS = 10.0
|
|||||||
class RateLimitCleanupMixin:
|
class RateLimitCleanupMixin:
|
||||||
def list_cleanup_runs(self, limit: int = 20, offset: int = 0) -> list[CleanupRunRow]:
|
def list_cleanup_runs(self, limit: int = 20, offset: int = 0) -> list[CleanupRunRow]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
|
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class RecordsEventsMixin:
|
|||||||
) -> UpdateResult:
|
) -> UpdateResult:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
||||||
current_ipv4 = existing["ipv4"] if existing else None
|
current_ipv4 = existing["ipv4"] if existing else None
|
||||||
current_ipv6 = existing["ipv6"] if existing else None
|
current_ipv6 = existing["ipv6"] if existing else None
|
||||||
@@ -70,7 +69,6 @@ class RecordsEventsMixin:
|
|||||||
update_ipv6: bool = True,
|
update_ipv6: bool = True,
|
||||||
) -> UpdateResult:
|
) -> UpdateResult:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
||||||
current_ipv4 = existing["ipv4"] if existing else None
|
current_ipv4 = existing["ipv4"] if existing else None
|
||||||
current_ipv6 = existing["ipv6"] 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]]:
|
def list_records_page(self, limit: int = 100, offset: int = 0) -> list[dict[str, str | None]]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT hostname, ipv4, ipv6, updated_at
|
SELECT hostname, ipv4, ipv6, updated_at
|
||||||
@@ -154,28 +151,45 @@ class RecordsEventsMixin:
|
|||||||
return self.list_update_events_page(limit=limit, offset=offset)
|
return self.list_update_events_page(limit=limit, offset=offset)
|
||||||
|
|
||||||
def list_update_events_page(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
|
def list_update_events_page(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
|
||||||
source_limit = limit + offset
|
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
rows = conn.execute(
|
||||||
event_rows = conn.execute(
|
|
||||||
"""
|
"""
|
||||||
|
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
|
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
|
||||||
FROM update_events
|
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
|
ORDER BY created_at DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
""",
|
""",
|
||||||
(source_limit, 0),
|
(limit, offset),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
rollup_rows = conn.execute(
|
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
|
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
|
||||||
FROM update_event_rollups
|
FROM update_event_rollups
|
||||||
ORDER BY last_seen_at DESC
|
)
|
||||||
LIMIT ? OFFSET ?
|
{where_clause}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(source_limit, 0),
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return _merge_event_rows(event_rows, rollup_rows, limit, offset)
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def count_update_events(self) -> int:
|
def count_update_events(self) -> int:
|
||||||
with self.connect() as conn:
|
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]:
|
def list_update_events_for_hostname(self, hostname: str, limit: int = 25) -> list[UpdateEventRow]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
event_rows = conn.execute(
|
event_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
|
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from .types import ScheduledTaskRow
|
from .types import ScheduledTaskRow
|
||||||
@@ -87,7 +86,6 @@ class SchedulerMixin:
|
|||||||
|
|
||||||
def list_scheduled_tasks(self) -> list[ScheduledTaskRow]:
|
def list_scheduled_tasks(self) -> list[ScheduledTaskRow]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at,
|
SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ def connect_database(database_path: Path) -> sqlite3.Connection:
|
|||||||
if str(database_path) != ":memory:":
|
if str(database_path) != ":memory:":
|
||||||
database_path.parent.mkdir(parents=True, exist_ok=True)
|
database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(database_path, check_same_thread=False)
|
conn = sqlite3.connect(database_path, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
configure_connection(conn)
|
configure_connection(conn)
|
||||||
return 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 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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS accounts (
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
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_security import client_ip, is_rate_limited_path, rate_limit_bucket
|
||||||
from ..ddns_store import DdnsStore
|
from ..ddns_store import DdnsStore
|
||||||
|
|
||||||
|
ACCESS_LOGGER = logging.getLogger("router_dyndns.access")
|
||||||
|
|
||||||
|
|
||||||
class RequestBodyTooLargeError(Exception):
|
class RequestBodyTooLargeError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -46,6 +51,7 @@ class RequestBodyLimitMiddleware:
|
|||||||
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
|
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def security_middleware(request: Request, call_next):
|
async def security_middleware(request: Request, call_next):
|
||||||
|
started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
content_length = int(request.headers.get("content-length", "0") or "0")
|
content_length = int(request.headers.get("content-length", "0") or "0")
|
||||||
except ValueError:
|
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)}"
|
key = f"{client_ip(request, settings)}:{rate_limit_bucket(request.url.path)}"
|
||||||
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
|
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
|
await run_in_threadpool(store.increment_metric, "rate_limit_hits_total")
|
||||||
return PlainTextResponse("rate limit exceeded", status_code=429)
|
return PlainTextResponse("rate limit exceeded", status_code=429)
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if is_secret_response(request):
|
if is_secret_response(request):
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
response.headers["Pragma"] = "no-cache"
|
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-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "same-origin"
|
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)
|
response.headers["Content-Security-Policy"] = content_security_policy(request.url.path)
|
||||||
if settings.public_base_url.startswith("https://"):
|
if settings.public_base_url.startswith("https://"):
|
||||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
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
|
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"
|
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:
|
def content_security_policy(path: str) -> str:
|
||||||
if path in {"/docs", "/redoc", "/openapi.json"}:
|
if path in {"/docs", "/redoc", "/openapi.json"}:
|
||||||
return (
|
return (
|
||||||
@@ -108,13 +137,30 @@ def content_security_policy(path: str) -> str:
|
|||||||
"form-action 'self'; "
|
"form-action 'self'; "
|
||||||
"frame-ancestors 'none'"
|
"frame-ancestors 'none'"
|
||||||
)
|
)
|
||||||
|
script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
|
||||||
return (
|
return (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self'; "
|
f"{script_src}"
|
||||||
"style-src 'self' https://cdn.jsdelivr.net; "
|
"style-src 'self'; "
|
||||||
"img-src 'self' data:; "
|
"img-src 'self' data:; "
|
||||||
"font-src 'self' https://cdn.jsdelivr.net; "
|
"font-src 'self'; "
|
||||||
"connect-src 'self'; "
|
"connect-src 'self'; "
|
||||||
"form-action 'self'; "
|
"form-action 'self'; "
|
||||||
"frame-ancestors 'none'"
|
"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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_CSS = """
|
BASE_CSS = """
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bs-primary: #000000;
|
--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;
|
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 {
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
@@ -158,7 +162,29 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.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 {
|
.hero-band {
|
||||||
@@ -367,9 +393,9 @@ input:focus,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
min-width: 34px;
|
min-width: 40px;
|
||||||
min-height: 34px;
|
min-height: 40px;
|
||||||
padding-inline: 14px;
|
padding-inline: 16px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,9 +450,13 @@ select:focus-visible {
|
|||||||
.btn-outline-danger {
|
.btn-outline-danger {
|
||||||
--bs-btn-color: var(--rp-danger);
|
--bs-btn-color: var(--rp-danger);
|
||||||
--bs-btn-border-color: var(--rp-line);
|
--bs-btn-border-color: var(--rp-line);
|
||||||
|
--bs-btn-bg: var(--rp-field);
|
||||||
--bs-btn-hover-color: #ffffff;
|
--bs-btn-hover-color: #ffffff;
|
||||||
--bs-btn-hover-bg: var(--rp-danger);
|
--bs-btn-hover-bg: var(--rp-danger);
|
||||||
--bs-btn-hover-border-color: 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 {
|
.text-primary {
|
||||||
@@ -461,6 +491,10 @@ select:focus-visible {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert code {
|
||||||
|
color: var(--rp-ink);
|
||||||
|
}
|
||||||
|
|
||||||
.copy-field {
|
.copy-field {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -538,53 +572,6 @@ select:focus-visible {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-table-scroll {
|
|
||||||
max-height: 560px;
|
|
||||||
overflow: auto;
|
|
||||||
background: var(--rp-surface);
|
|
||||||
border-color: var(--rp-line) !important;
|
|
||||||
border-radius: 16px !important;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table-scroll thead th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
background: var(--rp-surface-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-page-size {
|
|
||||||
width: auto;
|
|
||||||
min-width: 76px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table-shell .form-control {
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-table-shell {
|
|
||||||
max-width: 100%;
|
|
||||||
border: 1px solid var(--rp-line);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: var(--rp-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
--bs-pagination-color: var(--rp-muted);
|
|
||||||
--bs-pagination-bg: var(--rp-surface);
|
|
||||||
--bs-pagination-border-color: var(--rp-line);
|
|
||||||
--bs-pagination-hover-color: var(--rp-ink);
|
|
||||||
--bs-pagination-hover-bg: var(--rp-field);
|
|
||||||
--bs-pagination-hover-border-color: var(--rp-line);
|
|
||||||
--bs-pagination-active-color: #ffffff;
|
|
||||||
--bs-pagination-active-bg: #000000;
|
|
||||||
--bs-pagination-active-border-color: #000000;
|
|
||||||
--bs-pagination-disabled-color: var(--rp-mute);
|
|
||||||
--bs-pagination-disabled-bg: var(--rp-surface);
|
|
||||||
--bs-pagination-disabled-border-color: var(--rp-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
padding: 32px 0;
|
padding: 32px 0;
|
||||||
background: var(--rp-canvas);
|
background: var(--rp-canvas);
|
||||||
@@ -594,64 +581,6 @@ select:focus-visible {
|
|||||||
color: var(--rp-ink) !important;
|
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) {
|
@media (max-width: 860px) {
|
||||||
.section,
|
.section,
|
||||||
.hero-band {
|
.hero-band {
|
||||||
@@ -750,6 +679,146 @@ th {
|
|||||||
border-radius: 0 0 14px 14px;
|
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 {
|
.admin-table-shell .form-control {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
@@ -767,14 +836,5 @@ th {
|
|||||||
td {
|
td {
|
||||||
padding: 12px 9px;
|
padding: 12px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
""".strip()
|
""".strip()
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from .html_utils import SafeHtml, attr, escape, join_html, trusted
|
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>'
|
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
|
||||||
if links is None:
|
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>')
|
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 = """
|
||||||
theme_toggle = '<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch color theme">Light</button>'
|
<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(
|
return trusted(
|
||||||
f"""
|
f"""
|
||||||
<a class="skip-link" href="#main">Skip to content</a>
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
|
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
|
||||||
<div class="container">
|
<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">
|
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
|
||||||
{links}
|
{links}
|
||||||
{account_html}
|
{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}?myip=203.0.113.10'",
|
||||||
f"curl -sS '{update_base}?myipv6=2001:db8::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 class="card card-body gap-3 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<h3>Manual updates</h3>
|
<h3>Manual updates</h3>
|
||||||
<p class="section-copy">FRITZ!Box replaces <code><ipaddr></code> and <code><ip6addr></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>
|
</div>
|
||||||
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
|
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
|
||||||
{_copy_button("Copy curl examples", commands)}
|
{_copy_button("Copy curl", commands)}
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -107,7 +117,7 @@ def _fritzbox_fields(
|
|||||||
f"Kennwort: {password}",
|
f"Kennwort: {password}",
|
||||||
]
|
]
|
||||||
if management_url:
|
if management_url:
|
||||||
lines.append(f"Private status page: {management_url}")
|
lines.append(f"Private dashboard: {management_url}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,9 +125,9 @@ def _secret_notice() -> SafeHtml:
|
|||||||
return trusted(
|
return trusted(
|
||||||
"""
|
"""
|
||||||
<div class="alert alert-info my-3 py-2 small" role="note">
|
<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>Public:</strong> Domain name and CNAME target can be shared.
|
||||||
<strong>Secret:</strong> Update-URL, one-box update URL, optional Kennwort, private dashboard URL, and domain claim links control the hostname.
|
<strong>Secret:</strong> Update URLs, router password, dashboard link, and claim links can update or manage this hostname.
|
||||||
Secrets are hidden by default on screen; Copy still copies the real value.
|
Secrets are hidden on screen. Copy still copies the real value.
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -127,7 +137,7 @@ def _one_time_secret_notice() -> SafeHtml:
|
|||||||
return trusted(
|
return trusted(
|
||||||
"""
|
"""
|
||||||
<div class="alert alert-warning my-3 py-2 small" role="alert">
|
<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>
|
</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):
|
if not isinstance(body, SafeHtml):
|
||||||
raise TypeError("body must be a SafeHtml fragment built by a template helper")
|
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"""
|
return f"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -175,15 +231,23 @@ def _page(title: str, body: SafeHtml) -> str:
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
<meta name="description" content="{attr(description)}">
|
||||||
|
{canonical_html}
|
||||||
|
{social_html}
|
||||||
<title>{escape(title)}</title>
|
<title>{escape(title)}</title>
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<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="32x32" href="/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.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="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
<script src="/app.js" defer></script>
|
{preload_logo_html}
|
||||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
<script src="/theme.js?v=2"></script>
|
||||||
<link href="/app.css" rel="stylesheet">
|
<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>
|
</head>
|
||||||
<body>{body}{_site_footer()}</body>
|
<body>{body}{_site_footer()}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ from .templates_admin_tables import (
|
|||||||
_account_row,
|
_account_row,
|
||||||
_admin_table_pagination,
|
_admin_table_pagination,
|
||||||
_admin_table_toolbar,
|
_admin_table_toolbar,
|
||||||
_cleanup_row,
|
|
||||||
_domain_claim_row,
|
_domain_claim_row,
|
||||||
_event_row,
|
_event_row,
|
||||||
_scheduled_task_row,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +22,7 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="eyebrow">Admin action</p>
|
<p class="eyebrow">Admin action</p>
|
||||||
<h2>{escape(title)}</h2>
|
<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">
|
<div class="d-grid gap-2">
|
||||||
{"".join(rows)}
|
{"".join(rows)}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +38,7 @@ def _render_admin_page(
|
|||||||
cleanup_runs: list[dict[str, str | int]],
|
cleanup_runs: list[dict[str, str | int]],
|
||||||
scheduled_tasks: list[dict[str, str | int | None]],
|
scheduled_tasks: list[dict[str, str | int | None]],
|
||||||
update_events: 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_counts: dict[str, int],
|
||||||
table_offsets: dict[str, int],
|
table_offsets: dict[str, int],
|
||||||
table_limit: 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 """
|
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>
|
<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 """
|
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>
|
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
|
||||||
"""
|
"""
|
||||||
accounts_count = escape(table_counts.get("accounts", len(accounts)))
|
accounts_count = escape(table_counts.get("accounts", len(accounts)))
|
||||||
domain_count = escape(table_counts.get("domain_challenges", len(domain_challenges)))
|
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)))
|
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))
|
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(
|
domain_pager = _server_page_links(
|
||||||
admin_path,
|
admin_path,
|
||||||
@@ -77,18 +70,18 @@ def _render_admin_page(
|
|||||||
table_limit,
|
table_limit,
|
||||||
table_counts.get("domain_challenges", 0),
|
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))
|
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(
|
return _page(
|
||||||
"KarlDNS Admin",
|
"KarlDNS Admin",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
<main id="main">
|
<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">
|
<section class="section admin-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="eyebrow">KarlDNS aktiv</p>
|
<p class="eyebrow">KarlDNS</p>
|
||||||
<h1>Operator console</h1>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -96,17 +89,15 @@ def _render_admin_page(
|
|||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row g-4 align-items-start">
|
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row mb-4">
|
||||||
<div class="col-lg-7">
|
<div>
|
||||||
<p class="eyebrow">Service health</p>
|
<p class="eyebrow">Service health</p>
|
||||||
<h2>Scheduled cleanup</h2>
|
<h2>Service status</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>
|
</div>
|
||||||
<form method="post" action="{safe_admin_path}/cleanup" class="col-lg-5 card card-body gap-3" aria-label="Run cleanup now">
|
</div>
|
||||||
<input type="hidden" name="csrf" value="{csrf}">
|
{cleanup_panel}
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Run cleanup now</button>
|
<div class="mt-4">
|
||||||
</form>
|
{metrics_html}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Hostnames</p>
|
<p class="eyebrow">Hostnames</p>
|
||||||
<h2>Active credentials</h2>
|
<h2>Hostnames</h2>
|
||||||
<p class="section-copy mb-0">Showing up to {escape(len(accounts))} of {accounts_count} hostnames.</p>
|
<p class="section-copy mb-0">Showing {escape(len(accounts))} of {accounts_count}.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{_admin_table_toolbar("hostnames", "Search hostnames")}
|
{_admin_table_toolbar("hostnames", "Search hostnames")}
|
||||||
<div class="table-responsive border rounded admin-table-scroll">
|
<div class="table-responsive border rounded admin-table-scroll">
|
||||||
<table class="table mb-0">
|
<table class="table mb-0">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>{rows}</tbody>
|
<tbody>{rows}</tbody>
|
||||||
</table>
|
</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 class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Domain claims</p>
|
<p class="eyebrow">Domain claims</p>
|
||||||
<h2>Verification lifecycle</h2>
|
<h2>Domain verification</h2>
|
||||||
<p class="section-copy mb-0">Showing up to {escape(len(domain_challenges))} of {domain_count} claims.</p>
|
<p class="section-copy mb-0">Showing {escape(len(domain_challenges))} of {domain_count}.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{_admin_table_toolbar("domain-claims", "Search domain claims")}
|
{_admin_table_toolbar("domain-claims", "Search domain claims")}
|
||||||
<div class="table-responsive border rounded admin-table-scroll">
|
<div class="table-responsive border rounded admin-table-scroll">
|
||||||
<table class="table mb-0">
|
<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>
|
<tbody>{domain_rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,52 +146,19 @@ def _render_admin_page(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Updates</p>
|
<p class="eyebrow">Updates</p>
|
||||||
<h2>Recent events</h2>
|
<h2>Update 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{_admin_table_toolbar("update-events", "Search update events")}
|
{_admin_table_toolbar("update-events", "Search update events")}
|
||||||
<div class="table-responsive border rounded admin-table-scroll">
|
<div class="table-responsive border rounded admin-table-scroll">
|
||||||
<table class="table mb-0">
|
<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>
|
<tbody>{event_rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,6 +168,8 @@ def _render_admin_page(
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</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"""
|
return trusted(f"""
|
||||||
<nav class="d-flex align-items-center justify-content-end gap-2 mt-3" aria-label="Server-side table pages">
|
<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>
|
<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{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 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</a>
|
||||||
</nav>
|
</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))
|
||||||
@@ -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">
|
<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="csrf" value="{attr(csrf)}">
|
||||||
<input type="hidden" name="hostname" value="{hostname}">
|
<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="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="Rotate management link" aria-label="Rotate management link for {hostname}">Manage link</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="Rotate router password" aria-label="Rotate router password for {hostname}">Password</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>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="{safe_admin_path}/accounts/delete">
|
<form method="post" action="{safe_admin_path}/accounts/delete">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||||
<input type="hidden" name="hostname" value="{hostname}">
|
<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>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ def _render_management_page(
|
|||||||
{_top_nav()}
|
{_top_nav()}
|
||||||
<section class="hero-band compact">
|
<section class="hero-band compact">
|
||||||
<div class="container text-center d-grid justify-items-center gap-3">
|
<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>
|
<h1>{hostname}</h1>
|
||||||
<p class="lead">Anyone with this link can manage or delete this hostname. Keep it private.</p>
|
<p class="lead">This private link can manage this hostname. Keep it safe.</p>
|
||||||
<a class="btn btn-outline-primary rounded-pill" href="/">Back home</a>
|
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{message_html}
|
{message_html}
|
||||||
@@ -71,12 +71,12 @@ def _render_management_page(
|
|||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<p class="eyebrow">Router settings</p>
|
<p class="eyebrow">Router settings</p>
|
||||||
<h2>Router update settings</h2>
|
<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()}
|
{_one_time_secret_notice()}
|
||||||
{_secret_notice()}
|
{_secret_notice()}
|
||||||
<div class="d-flex gap-2 flex-wrap mt-3">
|
<div class="d-flex gap-2 flex-wrap mt-3">
|
||||||
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
|
{_copy_button("Copy router fields", fritzbox_fields)}
|
||||||
{_copy_button("Copy CNAME record", cname_record)}
|
{_copy_button("Copy CNAME", cname_record)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6 d-grid gap-2">
|
<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 class="card card-body gap-3 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Actions</p>
|
<p class="eyebrow">Actions</p>
|
||||||
<h3>Rotate dashboard secrets</h3>
|
<h3>Replace router 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>
|
<p class="section-copy">Create a new update URL or router password. Update your router after changing either one.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||||
<input type="hidden" name="action" value="update">
|
<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>
|
||||||
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||||
<input type="hidden" name="action" value="password">
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +134,7 @@ def _render_management_page(
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">History</p>
|
<p class="eyebrow">History</p>
|
||||||
<h2>Router updates</h2>
|
<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>
|
</div>
|
||||||
<div class="table-responsive border rounded admin-table-scroll">
|
<div class="table-responsive border rounded admin-table-scroll">
|
||||||
@@ -148,7 +148,7 @@ def _render_management_page(
|
|||||||
<section class="section danger-section">
|
<section class="section danger-section">
|
||||||
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Danger zone</p>
|
<p class="eyebrow">Delete</p>
|
||||||
<h2>Delete hostname</h2>
|
<h2>Delete hostname</h2>
|
||||||
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
|
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,6 +160,7 @@ def _render_management_page(
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
"""),
|
"""),
|
||||||
|
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _management_message(message: str | None) -> SafeHtml:
|
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:
|
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
|
||||||
password_status = "Enabled" if password_enabled else "Not enabled"
|
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 = ""
|
remove_form = ""
|
||||||
if password_enabled:
|
if password_enabled:
|
||||||
remove_form = f"""
|
remove_form = f"""
|
||||||
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password/remove">
|
<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)}">
|
<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>
|
<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>
|
</form>
|
||||||
"""
|
"""
|
||||||
current_password_field = ""
|
current_password_field = ""
|
||||||
if password_enabled:
|
if password_enabled:
|
||||||
current_password_field = """
|
current_password_field = """
|
||||||
<div>
|
<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>
|
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -203,7 +204,7 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
|
|||||||
<div class="col-lg-5">
|
<div class="col-lg-5">
|
||||||
<p class="eyebrow">Dashboard password</p>
|
<p class="eyebrow">Dashboard password</p>
|
||||||
<h2>Password protection</h2>
|
<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>
|
||||||
<div class="col-lg-7 d-grid gap-3">
|
<div class="col-lg-7 d-grid gap-3">
|
||||||
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password">
|
<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">
|
<div class="container text-center d-grid justify-items-center gap-3">
|
||||||
<p class="eyebrow">Dashboard locked</p>
|
<p class="eyebrow">Dashboard locked</p>
|
||||||
<h1>{hostname}</h1>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
@@ -263,9 +264,9 @@ def _render_management_unlock_page(
|
|||||||
</div>
|
</div>
|
||||||
<label class="form-check d-flex align-items-center gap-2">
|
<label class="form-check d-flex align-items-center gap-2">
|
||||||
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
|
<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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -285,15 +286,15 @@ def _management_domain_claim_panel(
|
|||||||
<div class="card card-body gap-3">
|
<div class="card card-body gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Custom domain</p>
|
<p class="eyebrow">Custom domain</p>
|
||||||
<h2>Claim a domain</h2>
|
<h2>Use your 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>
|
<p class="section-copy">Create a TXT record, verify it here, then create a hostname under that domain.</p>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/m/{attr(management_slug)}/domains/request" class="d-grid gap-3" aria-label="Create dashboard domain claim">
|
<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)}">
|
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||||
<label>Domain
|
<label>Domain
|
||||||
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
||||||
</label>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -312,7 +313,7 @@ def _management_domain_claim_panel(
|
|||||||
verification_name = service.verification_name(domain)
|
verification_name = service.verification_name(domain)
|
||||||
token = str(challenge["token"])
|
token = str(challenge["token"])
|
||||||
claim_url = service.domain_claim_url(challenge)
|
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}"
|
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("")
|
create_hostname = _management_custom_hostname_form(management_slug, claim_secret, csrf_token) if challenge.get("verified_at") else trusted("")
|
||||||
return trusted(
|
return trusted(
|
||||||
@@ -325,11 +326,11 @@ def _management_domain_claim_panel(
|
|||||||
<p class="eyebrow">Domain claim</p>
|
<p class="eyebrow">Domain claim</p>
|
||||||
<h2>{escape(domain)}</h2>
|
<h2>{escape(domain)}</h2>
|
||||||
<p class="section-copy">Status: {escape(status)}</p>
|
<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>
|
||||||
<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">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
{_copy_button("Copy TXT record", txt_bundle)}
|
{_copy_button("Copy TXT", txt_bundle)}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
{_copy_row("TXT name", verification_name, "Public")}
|
{_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="csrf" value="{attr(csrf_token)}">
|
||||||
<input type="hidden" name="domain" value="{attr(domain)}">
|
<input type="hidden" name="domain" value="{attr(domain)}">
|
||||||
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
|
<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>
|
</form>
|
||||||
{create_hostname}
|
{create_hostname}
|
||||||
</div>
|
</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">
|
<form method="post" action="/m/{attr(management_slug)}/domains/hostnames" class="d-grid gap-3" aria-label="Create verified custom hostname">
|
||||||
{csrf_field}
|
{csrf_field}
|
||||||
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
|
<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>
|
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
||||||
</label>
|
</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>
|
</form>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ def _render_public_home(
|
|||||||
message: str | None,
|
message: str | None,
|
||||||
challenge: dict[str, str | None] | None = None,
|
challenge: dict[str, str | None] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
suffix = escape(settings.hostname_suffix or "your DynDNS domain")
|
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)
|
created_html = _credentials_panel(service, created_account)
|
||||||
message_html = _message_band(message) if message and not challenge else ""
|
message_html = _message_band(message) if message and not challenge else ""
|
||||||
challenge_html = _custom_domain_flow(service, challenge, message)
|
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(
|
return _page(
|
||||||
"KarlDNS",
|
"KarlDNS",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
@@ -26,15 +28,15 @@ def _render_public_home(
|
|||||||
{_top_nav()}
|
{_top_nav()}
|
||||||
<section class="hero-band">
|
<section class="hero-band">
|
||||||
<div class="container text-center d-grid justify-items-center gap-3">
|
<div class="container text-center d-grid justify-items-center gap-3">
|
||||||
<img class="hero-logo" src="/logo.png" alt="" width="80" height="80">
|
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
|
||||||
<h1>KarlDNS for home routers.</h1>
|
<h1>Dynamic DNS for home routers.</h1>
|
||||||
<div class="install-pill mx-auto">
|
<div class="install-pill mx-auto">
|
||||||
<code>{demo_update_url}</code>
|
<code>{demo_update_url}</code>
|
||||||
</div>
|
</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">
|
<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-primary rounded-pill" href="#create">Create hostname</a>
|
||||||
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">See curl flow</a>
|
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -43,31 +45,31 @@ def _render_public_home(
|
|||||||
<section class="section" id="create">
|
<section class="section" id="create">
|
||||||
<div class="container narrow">
|
<div class="container narrow">
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<p class="eyebrow">Choose your hostname</p>
|
<p class="eyebrow">Hostname</p>
|
||||||
<h2>No account. No login.</h2>
|
<h2>No account needed.</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="section-copy mx-auto">Use a KarlDNS hostname now, or verify your own domain when you need it.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-4 align-items-stretch">
|
<div class="row g-4 align-items-stretch">
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card card-body h-100 gap-3">
|
<div class="card card-body h-100 gap-3">
|
||||||
<p class="eyebrow">Use our domain</p>
|
<p class="eyebrow">KarlDNS domain</p>
|
||||||
<h3>Generate a KarlDNS hostname</h3>
|
<h3>Create a hostname</h3>
|
||||||
<p class="section-copy mb-0">KarlDNS generates the hostname, secret update URL, and optional router compatibility credentials automatically.</p>
|
<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">
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card card-body h-100 gap-3">
|
<div class="card card-body h-100 gap-3">
|
||||||
<p class="eyebrow">Use your own domain</p>
|
<p class="eyebrow">Use your own domain</p>
|
||||||
<h3>Verify DNS ownership</h3>
|
<h3>Verify your domain</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>
|
<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">
|
<form method="post" action="/request-domain" class="d-grid gap-3" aria-label="Request a custom domain TXT challenge">
|
||||||
<label>Domain
|
<label>Domain
|
||||||
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
||||||
</label>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,8 +82,8 @@ def _render_public_home(
|
|||||||
</section>
|
</section>
|
||||||
<section class="section" id="manual-updates">
|
<section class="section" id="manual-updates">
|
||||||
<div class="container narrow text-center">
|
<div class="container narrow text-center">
|
||||||
<h2>Update from anything that can send GET.</h2>
|
<h2>Update from a router or script.</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>
|
<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">
|
<div class="terminal-card text-start mt-4">
|
||||||
<code># explicit IPv4</code>
|
<code># explicit IPv4</code>
|
||||||
<code>curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'</code>
|
<code>curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'</code>
|
||||||
@@ -94,9 +96,33 @@ def _render_public_home(
|
|||||||
{challenge_html}
|
{challenge_html}
|
||||||
</main>
|
</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(
|
return _page(
|
||||||
"Legal and Privacy",
|
"Legal and Privacy",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
@@ -105,23 +131,23 @@ def _render_privacy_page() -> str:
|
|||||||
<section class="hero-band compact">
|
<section class="hero-band compact">
|
||||||
<div class="container text-center d-grid justify-items-center gap-3">
|
<div class="container text-center d-grid justify-items-center gap-3">
|
||||||
<p class="eyebrow">Legal and privacy</p>
|
<p class="eyebrow">Legal and privacy</p>
|
||||||
<h1>KarlDNS keeps accountless DynDNS simple.</h1>
|
<h1>Privacy basics.</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>
|
<p class="lead">KarlDNS stores only the data needed to keep your hostname updated.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container narrow d-grid gap-4">
|
<div class="container narrow d-grid gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2>Data processed</h2>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h2>Secrets</h2>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h2>Logs and retention</h2>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
@@ -131,4 +157,20 @@ def _render_privacy_page() -> str:
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</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}/",
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
@@ -25,8 +25,8 @@ def _linux_installer_section() -> SafeHtml:
|
|||||||
<div class="container narrow">
|
<div class="container narrow">
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<p class="eyebrow">Linux cron updater</p>
|
<p class="eyebrow">Linux cron updater</p>
|
||||||
<h2>Install and uninstall should be boring.</h2>
|
<h2>Linux updater.</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>
|
<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>
|
||||||
<div class="card card-body gap-3">
|
<div class="card card-body gap-3">
|
||||||
<div class="terminal-card">
|
<div class="terminal-card">
|
||||||
@@ -39,11 +39,11 @@ def _linux_installer_section() -> SafeHtml:
|
|||||||
{_copy_button("Copy all commands", all_commands)}
|
{_copy_button("Copy all commands", all_commands)}
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-info my-0 py-2 small" role="note">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h3>Without sudo</h3>
|
<h3>User install</h3>
|
||||||
<p class="section-copy">User mode writes to <code>~/.local/bin</code>, <code>~/.config/karldns</code>, and your user crontab.</p>
|
<p class="section-copy">Use this when you do not want a system-wide install.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-card">
|
<div class="terminal-card">
|
||||||
<code>{escape(user_install)}</code>
|
<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 "")
|
claim_secret = attr(challenge.get("claim_secret") or "")
|
||||||
verification_name = service.verification_name(str(challenge["domain"]))
|
verification_name = service.verification_name(str(challenge["domain"]))
|
||||||
claim_url = service.domain_claim_url(challenge)
|
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}"
|
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
|
||||||
challenge_html = trusted(
|
challenge_html = trusted(
|
||||||
f"""
|
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>
|
<span class="badge text-bg-primary rounded-circle align-self-start">1</span>
|
||||||
<h3>Add the TXT record</h3>
|
<h3>Add the TXT record</h3>
|
||||||
<p class="small text-secondary mb-0 mt-2">{status}</p>
|
<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>
|
<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 record", txt_bundle)}
|
{_copy_button("Copy TXT", txt_bundle)}
|
||||||
{_copy_row("TXT name", verification_name, "Public")}
|
{_copy_row("TXT name", verification_name, "Public")}
|
||||||
{_copy_row("TXT value", token, "Public")}
|
{_copy_row("TXT value", token, "Public")}
|
||||||
{_copy_row("Private claim link", claim_url, "Secret")}
|
{_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">
|
<section class="section" id="custom-domain">
|
||||||
<div class="container narrow">
|
<div class="container narrow">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="eyebrow">Direct custom-domain publishing</p>
|
<p class="eyebrow">Custom domain</p>
|
||||||
<h2>Verify DNS ownership</h2>
|
<h2>Verify your domain</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="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>
|
||||||
<div class="row g-3 mt-4 justify-content-center">
|
<div class="row g-3 mt-4 justify-content-center">
|
||||||
{challenge_html}
|
{challenge_html}
|
||||||
@@ -129,24 +129,24 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
|
|||||||
f"""
|
f"""
|
||||||
<section class="section success-section">
|
<section class="section success-section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="eyebrow">Hostname generated</p>
|
<p class="eyebrow">Hostname ready</p>
|
||||||
<h2>{escape(hostname)}</h2>
|
<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()}
|
{_one_time_secret_notice()}
|
||||||
{_secret_notice()}
|
{_secret_notice()}
|
||||||
<div class="d-flex gap-2 flex-wrap my-3">
|
<div class="d-flex gap-2 flex-wrap my-3">
|
||||||
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
|
{_copy_button("Copy router fields", fritzbox_fields)}
|
||||||
{_copy_button("Copy one-box update URL", credentialed_update_url)}
|
{_copy_button("Copy full URL", credentialed_update_url)}
|
||||||
{_copy_button("Copy CNAME record", cname_record)}
|
{_copy_button("Copy CNAME", cname_record)}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
{_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("Domainnamen:", hostname, "Public")}
|
||||||
{_copy_row("CNAME target:", hostname, "Public")}
|
{_copy_row("CNAME target:", hostname, "Public")}
|
||||||
{_copy_row("Benutzername (optional):", username, "Public")}
|
{_copy_row("Benutzername (optional):", username, "Public")}
|
||||||
{_copy_row("Kennwort (optional):", password, "Secret")}
|
{_copy_row("Kennwort (optional):", password, "Secret")}
|
||||||
{_copy_row("Private status page:", management_url, "Secret")}
|
{_copy_row("Private dashboard:", management_url, "Secret")}
|
||||||
</div>
|
</div>
|
||||||
{_manual_update_panel(manual_commands)}
|
{_manual_update_panel(manual_commands)}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,15 +161,15 @@ def _custom_hostname_form(claim_secret: str) -> SafeHtml:
|
|||||||
<div class="col-lg-5">
|
<div class="col-lg-5">
|
||||||
<div class="card card-body h-100 gap-3">
|
<div class="card card-body h-100 gap-3">
|
||||||
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
|
<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">
|
<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="mode" value="custom">
|
||||||
<input type="hidden" name="claim_secret" value="{claim_secret}">
|
<input type="hidden" name="claim_secret" value="{claim_secret}">
|
||||||
<label>Verified hostname
|
<label>Verified hostname
|
||||||
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
||||||
</label>
|
</label>
|
||||||
<p class="section-copy mb-0">Router compatibility credentials are generated automatically after creation.</p>
|
<p class="section-copy mb-0">Router fields are created after this step.</p>
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Create credentials</button>
|
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,8 +183,8 @@ def _locked_custom_hostname_card() -> SafeHtml:
|
|||||||
<div class="col-lg-5">
|
<div class="col-lg-5">
|
||||||
<div class="card card-body h-100 gap-3 opacity-75">
|
<div class="card card-body h-100 gap-3 opacity-75">
|
||||||
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
|
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
|
||||||
<h3>Create router hostname</h3>
|
<h3>Create hostname</h3>
|
||||||
<p class="small text-secondary mb-0 mt-2">This unlocks after the TXT record is visible in public DNS.</p>
|
<p class="small text-secondary mb-0 mt-2">Available after KarlDNS can see the TXT record.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -22,12 +22,12 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--base-url URL KarlDNS server URL. Default: https://karldns.de
|
--base-url URL KarlDNS server URL. Default: https://karldns.de
|
||||||
--raw-base-url URL Script download base URL. Default: GitHub raw main branch
|
--raw-base-url URL Updater script download URL
|
||||||
--every MINUTES Cron interval, 1-60. Default: 5
|
--every MINUTES Update interval, 1-60. Default: 5
|
||||||
--mode MODE auto, system, or user. Default: auto
|
--mode MODE auto, system, or user. Default: auto
|
||||||
--update-url URL Existing private /u/<secret> update URL
|
--update-url URL Existing private update URL
|
||||||
--hostname NAME Optional display hostname for output/config
|
--hostname NAME Hostname to show after install
|
||||||
--dashboard-url URL Optional private dashboard URL for output/config
|
--dashboard-url URL Private dashboard URL to show after install
|
||||||
--ipv4 VALUE auto, off, or a static IPv4. Default: auto
|
--ipv4 VALUE auto, off, or a static IPv4. Default: auto
|
||||||
--ipv6 VALUE auto, off, or a static IPv6. Default: auto
|
--ipv6 VALUE auto, off, or a static IPv6. Default: auto
|
||||||
--no-cron Install files only, do not configure cron
|
--no-cron Install files only, do not configure cron
|
||||||
@@ -202,7 +202,7 @@ case "$KARLDNS_INSTALL_MODE" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
if [ "$KARLDNS_INSTALL_MODE" = "system" ] && [ "$(id -u)" -ne 0 ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ if [ -z "$KARLDNS_UPDATE_URL" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$KARLDNS_UPDATE_URL" ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
case "$KARLDNS_UPDATE_URL" in
|
case "$KARLDNS_UPDATE_URL" in
|
||||||
@@ -230,10 +230,10 @@ fi
|
|||||||
|
|
||||||
echo "KarlDNS installed."
|
echo "KarlDNS installed."
|
||||||
echo "Hostname: ${KARLDNS_HOSTNAME:-already configured}"
|
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"
|
echo "Mode: $KARLDNS_INSTALL_MODE"
|
||||||
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
|
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
|
||||||
echo "Interval: every $KARLDNS_INTERVAL_MINUTES minute(s)"
|
echo "Interval: every $KARLDNS_INTERVAL_MINUTES minutes"
|
||||||
else
|
else
|
||||||
echo "Cron: skipped"
|
echo "Cron: skipped"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ if [ -r "$CONFIG_FILE" ]; then
|
|||||||
. "$CONFIG_FILE"
|
. "$CONFIG_FILE"
|
||||||
fi
|
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_BASE_URL="${KARLDNS_BASE_URL:-https://karldns.de}"
|
||||||
KARLDNS_LOCK_FILE="${KARLDNS_LOCK_FILE:-/tmp/karldns-updater.lock}"
|
KARLDNS_LOCK_FILE="${KARLDNS_LOCK_FILE:-/tmp/karldns-updater.lock}"
|
||||||
KARLDNS_CACHE_FILE="${KARLDNS_CACHE_FILE:-${XDG_CACHE_HOME:-$HOME/.cache}/karldns/last-ip}"
|
KARLDNS_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"
|
ipv6="$KARLDNS_IPV6"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$ipv4" in *" "*) echo "KarlDNS: invalid IPv4 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: invalid IPv6 discovery result" >&2; exit 1 ;; esac
|
case "$ipv6" in *" "*) echo "KarlDNS: could not read a valid IPv6 address" >&2; exit 1 ;; esac
|
||||||
public_ip="${ipv4}|${ipv6}"
|
public_ip="${ipv4}|${ipv6}"
|
||||||
has_discovered_ip=0
|
has_discovered_ip=0
|
||||||
if [ -n "$ipv4" ] || [ -n "$ipv6" ]; then
|
if [ -n "$ipv4" ] || [ -n "$ipv6" ]; then
|
||||||
@@ -87,7 +87,7 @@ case "$response" in
|
|||||||
echo "KarlDNS: $response"
|
echo "KarlDNS: $response"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "KarlDNS: update failed: $response" >&2
|
echo "KarlDNS: update failed. Server said: $response" >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -13,7 +13,7 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--mode MODE auto, system, or user. Default: auto
|
--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
|
-h, --help Show this help
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -80,5 +80,5 @@ fi
|
|||||||
|
|
||||||
echo "KarlDNS uninstalled from $KARLDNS_INSTALL_MODE mode."
|
echo "KarlDNS uninstalled from $KARLDNS_INSTALL_MODE mode."
|
||||||
if [ "$KARLDNS_DELETE_CONFIG" = "0" ]; then
|
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
|
fi
|
||||||
@@ -27,20 +27,24 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["cache-control"] == "no-store"
|
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 "Operator console" in response.text
|
||||||
assert "Generate credentials" not in response.text
|
assert "Generate credentials" not in response.text
|
||||||
assert 'action="/admin/accounts"' 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-admin-table") == 3
|
||||||
assert response.text.count("data-table-search") == 4
|
assert response.text.count("data-table-search") == 3
|
||||||
assert response.text.count("data-table-page-size") == 4
|
assert response.text.count("data-table-page-size") == 3
|
||||||
assert response.text.count("data-table-pagination") == 4
|
assert response.text.count("data-table-pagination") == 3
|
||||||
assert "admin-table-scroll" in response.text
|
assert "admin-table-scroll" in response.text
|
||||||
assert "Scheduled cleanup" in response.text
|
assert "Cleanup" in response.text
|
||||||
assert "Scheduler state" in response.text
|
assert "Run now" in response.text
|
||||||
assert "database lease" in response.text
|
assert "Next run" in response.text
|
||||||
assert "Active credentials" 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 "Domain claims" in response.text
|
||||||
assert "Recent events" in response.text
|
assert "Update events" in response.text
|
||||||
assert "example.net" 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 len(records["items"]) == 1
|
||||||
assert events["limit"] == 1
|
assert events["limit"] == 1
|
||||||
assert events["offset"] == 1
|
assert events["offset"] == 1
|
||||||
|
assert "next_before" in events
|
||||||
assert events["total"] == 2
|
assert events["total"] == 2
|
||||||
assert len(events["items"]) == 1
|
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:
|
def test_admin_posts_require_csrf(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"))
|
||||||
client = TestClient(app)
|
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"},
|
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
|
||||||
)
|
)
|
||||||
assert update.status_code == 200
|
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
|
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||||
|
|
||||||
management = client.post(
|
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"},
|
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
|
||||||
)
|
)
|
||||||
assert management.status_code == 200
|
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
|
assert "/m/" in management.text
|
||||||
|
|
||||||
password = client.post(
|
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"},
|
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
|
||||||
)
|
)
|
||||||
assert password.status_code == 200
|
assert password.status_code == 200
|
||||||
assert "Router password rotated" in password.text
|
assert "Router password changed" in password.text
|
||||||
assert "One-box update URL:" in password.text
|
assert "Full update URL:" in password.text
|
||||||
assert "/nic/update?" in password.text
|
assert "/nic/update?" in password.text
|
||||||
assert "Kennwort (optional):" 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)}
|
statuses = {event["status"] for event in store.list_update_events_for_hostname("home.example.net", 10)}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert docs.status_code == 200
|
assert docs.status_code == 200
|
||||||
assert redoc.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 "unsafe-inline" in docs.headers["content-security-policy"]
|
||||||
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
|
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
|
||||||
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
|
assert "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"})
|
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||||
assert created.status_code == 201
|
assert created.status_code == 201
|
||||||
assert created.headers["cache-control"] == "no-store"
|
assert created.headers["cache-control"] == "no-store"
|
||||||
|
assert created.headers["x-robots-tag"] == "noindex, nofollow"
|
||||||
body = created.json()
|
body = created.json()
|
||||||
assert body["hostname"].endswith(".ddns.example.net")
|
assert body["hostname"].endswith(".ddns.example.net")
|
||||||
assert body["update_url"].startswith("http://ddns.example.net/u/")
|
assert body["update_url"].startswith("http://ddns.example.net/u/")
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
|
|||||||
"rate_limits",
|
"rate_limits",
|
||||||
"records",
|
"records",
|
||||||
"scheduled_tasks",
|
"scheduled_tasks",
|
||||||
|
"service_metrics",
|
||||||
"update_events",
|
"update_events",
|
||||||
}.issubset(tables)
|
}.issubset(tables)
|
||||||
assert len(store.list_scheduled_tasks()) == 1
|
assert len(store.list_scheduled_tasks()) == 1
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -5,8 +7,9 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import router_dyndns.ddns_service as ddns_service
|
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_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:
|
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)
|
client = TestClient(app)
|
||||||
|
|
||||||
first = client.get("/m/not-a-real-token")
|
first = client.get(f"/m/{'a' * 43}")
|
||||||
second = client.get("/m/another-token")
|
second = client.get(f"/m/{'b' * 43}")
|
||||||
|
|
||||||
assert first.status_code == 404
|
assert first.status_code == 404
|
||||||
assert second.status_code == 429
|
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:
|
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:
|
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"):
|
with pytest.raises(RuntimeError, match="DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE"):
|
||||||
make_app(
|
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:
|
def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
|
||||||
import dns.query
|
import dns.query
|
||||||
import dns.rcode
|
import dns.rcode
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Update-URL:" in response.text
|
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 "CNAME target:" in response.text
|
||||||
assert "Benutzername (optional):" in response.text
|
assert "Benutzername (optional):" in response.text
|
||||||
assert "Kennwort (optional):" in response.text
|
assert "Kennwort (optional):" in response.text
|
||||||
assert "Copy complete FRITZ!Box fields" in response.text
|
assert "Copy router fields" in response.text
|
||||||
assert "Copy CNAME record" in response.text
|
assert "Copy CNAME" in response.text
|
||||||
assert "Manual updates" in response.text
|
assert "Manual updates" in response.text
|
||||||
assert "curl -sS" in response.text
|
assert "curl -sS" in response.text
|
||||||
assert "203.0.113.10" 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 response.status_code == 200
|
||||||
assert "Update-URL:" in response.text
|
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 "CNAME target:" in response.text
|
||||||
assert "Benutzername (optional):" in response.text
|
assert "Benutzername (optional):" in response.text
|
||||||
assert "Kennwort (optional):" in response.text
|
assert "Kennwort (optional):" in response.text
|
||||||
assert "Private status page:" in response.text
|
assert "Private dashboard:" in response.text
|
||||||
assert "Copy complete FRITZ!Box fields" in response.text
|
assert "Copy router fields" in response.text
|
||||||
assert "Copy CNAME record" in response.text
|
assert "Copy CNAME" in response.text
|
||||||
assert "FRITZ!Box replaces" in response.text
|
assert "FRITZ!Box fills in" in response.text
|
||||||
assert "Copy curl examples" in response.text
|
assert "Copy curl" in response.text
|
||||||
assert response.text.count("Secret") >= 3
|
assert response.text.count("Secret") >= 3
|
||||||
assert response.text.count("Public") >= 3
|
assert response.text.count("Public") >= 3
|
||||||
assert ".ddns.example.net" in response.text
|
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']}")
|
management = client.get(f"/m/{account['management_slug']}")
|
||||||
assert management.status_code == 200
|
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 "home.ddns.example.net" in management.text
|
||||||
assert "CNAME target:" in management.text
|
assert "CNAME target:" in management.text
|
||||||
assert "Benutzername (optional):" in management.text
|
assert "Benutzername (optional):" in management.text
|
||||||
assert "Kennwort (optional):" in management.text
|
assert "Kennwort (optional):" in management.text
|
||||||
assert "Copy complete FRITZ!Box fields" in management.text
|
assert "Copy router fields" in management.text
|
||||||
assert "Copy CNAME record" in management.text
|
assert "Copy CNAME" in management.text
|
||||||
assert "Manual updates" in management.text
|
assert "Manual updates" in management.text
|
||||||
assert "curl -sS" in management.text
|
assert "curl -sS" in management.text
|
||||||
assert "Public:" 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"},
|
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
|
||||||
)
|
)
|
||||||
assert bad_unlock.status_code == 401
|
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
|
assert "Update-URL:" not in bad_unlock.text
|
||||||
|
|
||||||
good_unlock = locked_client.post(
|
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 change.status_code == 400
|
||||||
assert remove.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")
|
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']}")
|
dashboard = client.get(f"/m/{account['management_slug']}")
|
||||||
assert dashboard.status_code == 200
|
assert dashboard.status_code == 200
|
||||||
assert "Rotate Update-URL" in dashboard.text
|
assert "New update URL" in dashboard.text
|
||||||
assert "Rotate router credentials" in dashboard.text
|
assert "New password" in dashboard.text
|
||||||
assert "data-secret-toggle" in dashboard.text
|
assert "data-secret-toggle" in dashboard.text
|
||||||
assert 'type="password"' 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"})
|
rotated_update = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
|
||||||
assert rotated_update.status_code == 200
|
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 "data-secret-input" in rotated_update.text
|
||||||
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
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"})
|
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 rotated_password.status_code == 200
|
||||||
assert "Router credentials rotated" in rotated_password.text
|
assert "Router password changed" in rotated_password.text
|
||||||
assert "One-box update URL:" in rotated_password.text
|
assert "Full update URL:" in rotated_password.text
|
||||||
assert "Kennwort (optional):" in rotated_password.text
|
assert "Kennwort (optional):" in rotated_password.text
|
||||||
assert "data-secret-toggle" in rotated_password.text
|
assert "data-secret-toggle" in rotated_password.text
|
||||||
assert client.get(
|
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']}")
|
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"})
|
challenge = client.post(f"/m/{account['management_slug']}/domains/request", data={"csrf": _csrf(dashboard.text), "domain": "example.net"})
|
||||||
assert challenge.status_code == 200
|
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 name" in challenge.text
|
||||||
assert "TXT value" in challenge.text
|
assert "TXT value" in challenge.text
|
||||||
assert "Claim secret" 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 verified.status_code == 200
|
||||||
assert "Domain verified" in verified.text
|
assert "Domain verified" in verified.text
|
||||||
assert "Create custom hostname" in verified.text
|
assert "Create hostname" in verified.text
|
||||||
|
|
||||||
created = client.post(
|
created = client.post(
|
||||||
f"/m/{account['management_slug']}/domains/hostnames",
|
f"/m/{account['management_slug']}/domains/hostnames",
|
||||||
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
|
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||||
)
|
)
|
||||||
assert created.status_code == 200
|
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 "home.example.net" in created.text
|
||||||
assert "Update-URL:" 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:
|
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})
|
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
|
||||||
assert verify.status_code == 200
|
assert verify.status_code == 200
|
||||||
assert "Create credentials" in verify.text
|
assert "Create hostname" in verify.text
|
||||||
|
|
||||||
created = client.post(
|
created = client.post(
|
||||||
"/accounts",
|
"/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 'href="/admin"' not in response.text
|
||||||
assert "Router login name" not in response.text
|
assert "Router login name" not in response.text
|
||||||
assert 'name="username"' 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 "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/install.sh | sudo sh" in response.text
|
||||||
assert "curl -fsSL https://karldns.de/uninstall.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("/")
|
response = client.get("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert '<script src="/app.js" defer></script>' in response.text
|
assert '<script src="/theme.js?v=2"></script>' in response.text
|
||||||
assert '<link href="/app.css" rel="stylesheet">' 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 "<style>" not in response.text
|
||||||
assert "script-src 'self'" in response.headers["content-security-policy"]
|
assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"]
|
||||||
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
|
assert "style-src 'self'" in response.headers["content-security-policy"]
|
||||||
assert "style-src 'self' https://cdn.jsdelivr.net" in response.headers["content-security-policy"]
|
|
||||||
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
|
assert "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")
|
app_css = client.get("/app.css")
|
||||||
assert app_css.status_code == 200
|
assert app_css.status_code == 200
|
||||||
assert "text/css" in app_css.headers["content-type"]
|
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:
|
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)
|
client = TestClient(app)
|
||||||
|
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
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 "Made by Karl" in response.text
|
||||||
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
|
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
|
||||||
assert 'href="https://karl.fail"' in response.text
|
assert 'href="https://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")
|
privacy = client.get("/privacy")
|
||||||
assert privacy.status_code == 200
|
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 "Legal and privacy" in privacy.text
|
||||||
assert "Data processed" 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
|
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:
|
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"))
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
client = TestClient(app)
|
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
|
assert 'href="/site.webmanifest"' in response.text
|
||||||
for path in [
|
for path in [
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
|
"/bootstrap-subset.css",
|
||||||
"/favicon-32x32.png",
|
"/favicon-32x32.png",
|
||||||
"/favicon-16x16.png",
|
"/favicon-16x16.png",
|
||||||
"/apple-touch-icon.png",
|
"/apple-touch-icon.png",
|
||||||
|
"/logo-nav.webp",
|
||||||
|
"/logo.webp",
|
||||||
"/logo-192.png",
|
"/logo-192.png",
|
||||||
"/logo-512.png",
|
"/logo-512.png",
|
||||||
|
"/social-preview.png",
|
||||||
|
"/social-preview.webp",
|
||||||
]:
|
]:
|
||||||
asset = client.get(path)
|
asset = client.get(path)
|
||||||
assert asset.status_code == 200
|
assert asset.status_code == 200
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import router_dyndns.ddns_service as ddns_service
|
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:
|
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
|
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 test_programming_error_during_publish_is_not_mapped_to_dns_failure(tmp_path: Path, monkeypatch) -> None:
|
||||||
def fail_publish(result, settings):
|
def fail_publish(result, settings):
|
||||||
raise AssertionError("publish bug")
|
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 response.text == "good 203.0.113.9"
|
||||||
assert len(published) == 1
|
assert len(published) == 1
|
||||||
assert store.list_records()[0]["ipv4"] == "203.0.113.9"
|
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:
|
def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
|||||||