Prepare KarlDNS production deployment

This commit is contained in:
StasonJatham committed 2026-05-21 17:23:08 +02:00
1 parent 5a90bd9d87
commit ce5b4e0ff2
101 files changed
+3362 -2016

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ DDNS_TTL=60
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
# DDNS_MAX_REQUEST_BODY_BYTES=16384
# DDNS_REDACTED_ACCESS_LOG=0
# DDNS_CLEANUP_PENDING_ACCOUNT_HOURS=24
# DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
# DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320
# DDNS_CLEANUP_INTERVAL_SECONDS=3600
+1
View File
@@ -14,3 +14,4 @@ build/
tmp/
backend_audit_report.md
security_best_practices_report.md
output/playwright/
+6 -5
View File
@@ -1,12 +1,13 @@
FROM python:3.12-slim
FROM python:3.14-alpine
ENV DDNS_DATABASE=/data/ddns.sqlite3
WORKDIR /app
COPY pyproject.toml README.md /app/
COPY router_dyndns /app/router_dyndns
RUN pip install --no-cache-dir .
RUN adduser --system --group --home /app karldns \
COPY pyproject.toml README.md requirements.txt /app/
COPY karldns /app/karldns
RUN pip install --no-cache-dir -r requirements.txt . \
&& addgroup -S karldns \
&& adduser -S -G karldns -h /app karldns \
&& mkdir -p /data \
&& chown -R karldns:karldns /app /data
USER karldns
+100 -86
View File
@@ -11,19 +11,19 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control
## Why KarlDNS?
- **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure.
- **FRITZ!Box compatible**: copy the generated `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` straight into the German FRITZ!Box DynDNS form.
- **FRITZ!Box compatible**: copy the generated `Update-URL` and `Domainnamen` straight into the German FRITZ!Box DynDNS form.
- **CNAME-friendly hostnames**: users can use the generated hostname directly, or point `home.example.com` to it with a CNAME.
- **No registration required**: generated hostnames use private magic links, with no accounts or login flow.
- **No registration required**: generated hostnames use private bearer links, with no accounts or login flow.
- **CNAME alias support**: users create one KarlDNS hostname, then point `home.example.com` to it at their own DNS provider.
- **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support.
- **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
- **API first**: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under `/api/v1`.
- **API first**: small built-in API guide, OpenAPI JSON, and endpoints under `/api/v1`.
## Screenshots
### Create Router Credentials
### Create A Hostname
![Generated FRITZ!Box DynDNS credentials](docs/screenshots/generated-credentials.png)
![Generated KarlDNS hostname settings](docs/screenshots/generated-credentials.png)
## Logo And Social Preview
@@ -31,12 +31,12 @@ KarlDNS ships web-ready brand assets so self-hosted instances and shared links l
| 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. |
| `karldns/assets/logo-nav.webp` | Small navigation logo. |
| `karldns/assets/logo.webp` | Optimized homepage logo. |
| `karldns/assets/logo-192.png` and `logo-512.png` | PWA, app icon, and manifest icons. |
| `karldns/assets/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, `apple-touch-icon.png` | Browser and mobile icons. |
| `karldns/assets/social-preview.png` | Open Graph and Twitter large-card preview for reliable crawlers. |
| `karldns/assets/social-preview.webp` | Optimized WebP social preview for modern clients. |
## What It Does
@@ -44,10 +44,11 @@ KarlDNS lets you host your own managed DynDNS service:
1. A user opens your KarlDNS URL.
2. They generate a random hostname like `a1b2c3d4.karldns.de`.
3. KarlDNS shows the exact router settings for the DynDNS form.
4. The router periodically calls the update URL with its current public IPv4/IPv6 address.
5. KarlDNS publishes the matching DNS records through your configured DNS backend.
6. If the user wants their own DNS name, they can create a CNAME to the generated hostname.
3. KarlDNS shows the exact router settings and one-time secrets.
4. The user confirms they saved the values, which registers the hostname.
5. The router periodically calls the update URL with its current public IPv4/IPv6 address.
6. KarlDNS publishes the matching DNS records through your configured DNS backend.
7. If the user wants their own DNS name, they can create a CNAME to the generated hostname.
That means users always have a current hostname for VPN, remote access, self-hosted apps, home servers, NAS devices, and lab networks.
@@ -64,13 +65,13 @@ That means users always have a current hostname for VPN, remote access, self-hos
| Area | Support |
| --- | --- |
| Router update URLs | FRITZ!Box custom provider URL, `/u/<slug>`, and `/nic/update` compatibility |
| Router update URLs | FRITZ!Box custom provider URL under `/api/v1/router/update/<secret>` |
| DNS records | A and AAAA |
| DNS providers | Cloudflare API and RFC 2136 dynamic DNS |
| Generated hostnames | Random provider-owned names that can be used directly or as CNAME targets |
| User-owned DNS names | CNAME only. KarlDNS publishes the generated provider-owned hostname; the user's DNS provider serves the alias |
| Persistence | SQLite with WAL mode |
| API | FastAPI, OpenAPI, Swagger UI, ReDoc |
| API | FastAPI, small API docs page, OpenAPI JSON |
| Auth model | No user accounts. Private bearer links for hostnames and dashboards |
| Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example |
@@ -100,12 +101,12 @@ Open:
- Web UI: `http://localhost:8080/`
- Admin UI: `http://localhost:8080/admin`
- Swagger UI: `http://localhost:8080/docs`
- ReDoc: `http://localhost:8080/redoc`
- API guide: `http://localhost:8080/docs`
- OpenAPI JSON: `http://localhost:8080/openapi.json`
Admin auth uses HTTP Basic auth. The username can be any value; the password is `DDNS_ADMIN_PASSWORD`.
The public OpenAPI schema documents the developer API under `/api/v1`. Admin/operator endpoints are intentionally omitted from Swagger UI and ReDoc.
The API guide documents the public endpoints plus the authenticated operator API. OpenAPI JSON remains available for tools.
## Production Setup
@@ -144,35 +145,23 @@ export DDNS_TTL=60
## FRITZ!Box DynDNS Setup
Create a hostname in the web UI. For the normal accountless flow, the generated `Update-URL` already contains the secret update slug. Put that URL into the router and use the generated hostname as `Domainnamen`.
KarlDNS also shows optional compatibility fields for routers that insist on separate credentials:
Create a hostname in the web UI, copy the generated values, then click **Register hostname**. The generated `Update-URL` contains the secret update slug. Put that URL into the router and use the generated hostname as `Domainnamen`.
- `Update-URL`
- `Domainnamen`
- `CNAME target`
- `Benutzername` (optional)
- `Kennwort` (optional)
- `Private dashboard`
- `Register URL`
Copy the router password and full update URL when they are generated or rotated. They are intentionally not recoverable from the dashboard later.
Copy the private URLs when they are generated or rotated. They are intentionally hidden by default on the dashboard to reduce shoulder-surfing risk.
The generated FRITZ!Box URL uses the native placeholders:
```text
https://karldns.de/u/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>
https://karldns.de/api/v1/router/update/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>
```
FRITZ!Box replaces `<ipaddr>` and `<ip6addr>` with the current WAN IPv4/IPv6 address and sends an HTTP GET request to that URL whenever it detects an address change. KarlDNS returns DynDNS-style responses such as `good 203.0.113.10`, `nochg 203.0.113.10`, `badauth`, or `nohost`.
This URL is enough for routers that only expose a single update URL field and do not send separate credentials. KarlDNS also shows a `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:
```text
https://karldns.de/nic/update?hostname=<generated-hostname>&myip=<ipaddr>&myipv6=<ip6addr>&username=<username>&password=<pass>
```
Prefer `/u/<slug>` for managed service use because the router request cannot change the hostname.
FRITZ!Box replaces `<ipaddr>` and `<ip6addr>` with the current WAN IPv4/IPv6 address and sends an HTTP GET request whenever it detects an address change. KarlDNS returns simple DynDNS-style responses such as `good 203.0.113.10`, `nochg 203.0.113.10`, `badip`, or `911 dns publish failed`.
## Manual Updates And Automation
@@ -180,24 +169,26 @@ Every generated hostname can be updated with curl, cron, a shell script, Home As
```bash
# Explicit IPv4 update
curl -sS 'https://karldns.de/u/<random-update-slug>?myip=203.0.113.10'
curl -sS 'https://karldns.de/api/v1/router/update/<random-update-slug>?myip=203.0.113.10'
# Explicit IPv6 update
curl -sS 'https://karldns.de/u/<random-update-slug>?myipv6=2001:db8::10'
curl -sS 'https://karldns.de/api/v1/router/update/<random-update-slug>?myipv6=2001:db8::10'
# Use the request source IP
curl -sS 'https://karldns.de/u/<random-update-slug>'
```
The `/nic/update` compatibility endpoint also works for clients that expect hostname and credentials in the URL:
```bash
curl -sS 'https://karldns.de/nic/update?hostname=a1b2c3d4.karldns.de&username=router&password=<password>&myip=203.0.113.10'
curl -sS 'https://karldns.de/api/v1/router/update/<random-update-slug>'
```
### Linux Cron Installer
For Linux servers, Proxmox hosts, NAS boxes, and small home-lab machines that are always on, KarlDNS ships a cron updater. It creates a hostname if you do not pass one, installs a tiny updater script, schedules it with cron, runs the first update, and prints the hostname plus private dashboard URL.
For Linux servers, Proxmox hosts, NAS boxes, and small home-lab machines that are always on, KarlDNS ships a cron updater. It creates and registers a hostname if you do not pass one, installs a tiny updater script, schedules it with cron, runs the first update, and prints the hostname plus private dashboard URL.
The installer is POSIX `sh` and works on common Linux distributions including Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts. It needs `curl` and cron. On Alpine, install those first if they are not present:
```bash
apk add curl
rc-service crond start
rc-update add crond
```
System-wide install:
@@ -211,17 +202,11 @@ The same script is hosted in GitHub and can be used directly:
curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sudo sh
```
User-level install without sudo:
```bash
curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user
```
Use an existing KarlDNS update URL:
```bash
curl -fsSL https://karldns.de/install.sh | sudo sh -s -- \
--update-url 'https://karldns.de/u/<random-update-slug>' \
--update-url 'https://karldns.de/api/v1/router/update/<random-update-slug>' \
--hostname 'home.karldns.de'
```
@@ -229,23 +214,29 @@ Common options:
```bash
--every 5 # cron interval in minutes, default 5
--mode user # install without root into ~/.local, ~/.config, and the user's crontab
--ipv4 auto # auto, off, or a static IPv4
--ipv6 auto # auto, off, or a static IPv6
--no-cron # install files only
--no-run # skip the first update
```
The updater discovers the public address through `https://karldns.de/ip` and sends it to the private update URL. If explicit discovery fails, it falls back to calling the update URL without IP parameters so KarlDNS can use the request source address. Re-running the installer is safe: it replaces the managed cron block and keeps unrelated cron entries intact.
The updater calls the private update URL. By default KarlDNS uses the request source address; explicit `--ipv4` or `--ipv6` values can be configured when needed. Re-running the installer is safe: it reuses an existing `updater.env`, refreshes the updater script, replaces only the managed cron block, and keeps unrelated cron entries intact.
Installed files in system mode:
```text
/usr/local/bin/karldns-update
/etc/karldns/updater.env
/etc/cron.d/karldns-updater
/var/cache/karldns/last-ip
```
Cron is installed where the OS expects it:
- Debian/Ubuntu/Fedora/Arch style: `/etc/cron.d/karldns-updater`
- Alpine/BusyBox style: managed block in `/etc/crontabs/root`
- Fallback: managed block in root's `crontab`
Installed files in user mode:
```text
@@ -254,7 +245,7 @@ Installed files in user mode:
~/.cache/karldns/last-ip
```
`updater.env` is written with mode `600` because it contains the private update URL and dashboard URL. The hostname itself is not secret. The update URL and dashboard URL are secrets.
`updater.env` is written with mode `600` because it contains private URLs. The hostname itself is not secret. The update URL, registration URL, and dashboard URL are secrets.
Uninstall, keeping the private config by default:
@@ -262,7 +253,7 @@ Uninstall, keeping the private config by default:
curl -fsSL https://karldns.de/uninstall.sh | sudo sh
```
Remove the private config and last-IP cache too:
Clean uninstall, including saved private URLs:
```bash
curl -fsSL https://karldns.de/uninstall.sh | sudo sh -s -- --delete-config
@@ -282,31 +273,52 @@ To use your own domain, create a KarlDNS hostname first, then create a CNAME at
home.example.com. CNAME a1b2c3d4.karldns.de.
```
Put the KarlDNS `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` into the router. The router updates `a1b2c3d4.karldns.de`; `home.example.com` follows through the CNAME.
Put the KarlDNS `Update-URL` and `Domainnamen` into the router. The router updates `a1b2c3d4.karldns.de`; `home.example.com` follows through the CNAME.
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.
KarlDNS does not verify, claim, or publish records inside user-owned domains. Your DNS provider keeps serving `home.example.com`; KarlDNS only maintains the generated `*.karldns.de` target.
The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, router password, private dashboard URL, and full credentialed update URL 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, registration URL, and private dashboard URL 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.
## HTTP API
The JSON API is available under `/api/v1`.
The JSON API is available under `/api/v1`. The public API has three surfaces: admin data, user dashboard data with history, and router registration/update calls.
| Endpoint | Purpose |
| --- | --- |
| `POST /api/v1/hostnames/magic` | Create an anonymous provider-owned hostname. |
| `GET /api/v1/management/{management_slug}` | Inspect a hostname by private management link. |
| `DELETE /api/v1/management/{management_slug}` | Delete a hostname and its DNS records. |
| `GET /api/v1/updates/{update_slug}` | JSON update endpoint for routers or automation. |
| `POST /api/v1/hostnames` | Create an anonymous provider-owned hostname in pending state. |
| `GET /api/v1/hostnames/{dashboard_secret}` | Inspect a hostname and recent update history by private dashboard secret. |
| `POST /api/v1/hostnames/{dashboard_secret}/rotate-update-url` | Replace the private router update URL. |
| `DELETE /api/v1/hostnames/{dashboard_secret}` | Delete a hostname and its DNS records. |
| `GET or POST /api/v1/router/register/{dashboard_secret}` | Register a pending hostname after secrets are saved. |
| `GET or POST /api/v1/router/update/{update_secret}` | Update a hostname from FRITZ!Box, another router, curl, or a cron script. |
| `GET /api/v1/admin/dashboard` | Authenticated operator summary, metrics, cleanup state, and recent history. |
| `GET /api/v1/admin/events` | Authenticated paginated update history. |
| `GET /api/v1/admin/hostnames` | Authenticated paginated hostname inventory. |
| `GET /api/v1/admin/records` | Authenticated paginated DNS record inventory. |
Example:
```bash
curl -sS https://karldns.de/api/v1/hostnames/magic \
-H 'content-type: application/json' \
-d '{"username":"home-router"}'
curl -sS -X POST https://karldns.de/api/v1/hostnames
```
The create response includes `registration_url`, `update_url`, and `dashboard_url`. Save the returned secrets, then `POST` the registration URL before using the update URL. Registration publishes the initial DNS record from the request IP; scripts may also pass `myip` or `myipv6` on the registration URL. Pending hostnames that are never registered are cleaned up automatically.
Use the dashboard secret to rotate the update URL:
```bash
curl -sS -X POST https://karldns.de/api/v1/hostnames/<dashboard-secret>/rotate-update-url
```
The response contains a replacement `update_url`. The old update URL stops working immediately.
Operator API calls use the same HTTP Basic auth password as the admin UI:
```bash
curl -sS -u admin:<admin-password> https://karldns.de/api/v1/admin/dashboard
curl -sS -u admin:<admin-password> 'https://karldns.de/api/v1/admin/events?limit=25'
```
## Docker
@@ -320,7 +332,7 @@ docker compose up -d --build
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 uses the current Python 3.14 Alpine base image and installs production dependencies from `requirements.txt`. The compose file defaults `DDNS_DATABASE` to `/data/ddns.sqlite3`, and the compose volume persists `/data`.
The default container command runs one Uvicorn worker. Keep that deployment shape unless you have tested multi-worker update behavior for your traffic pattern; rate-limit counters and per-host update locks are process-local.
@@ -333,10 +345,11 @@ The default container command runs one Uvicorn worker. Keep that deployment shap
- 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`.
- Set `DDNS_PUBLIC_BASE_URL` to the real public URL. KarlDNS uses it for generated links, code examples, API docs, canonical URLs, and the default trusted host.
- 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`.
- Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers.
- Keep `/docs` and `/redoc` available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth.
- Keep `/docs` available if you want the small developer surface. Operator API routes are documented and require Basic auth; admin UI form routes stay hidden.
- Leave cleanup enabled. The cleanup scheduler is stored in SQLite with a task row, next run time, status, last error, and a database lease so only one app process runs a due cleanup job.
- Back up `/data/ddns.sqlite3`.
- Run one Uvicorn worker with SQLite unless you have tested your deployment under load. Scheduled cleanup is lease-protected across workers, but rate-limit state and per-host update locks are process-local.
@@ -349,11 +362,13 @@ Security and cleanup defaults:
| `DDNS_RATE_LIMIT_PER_MINUTE` | `60` | Public update/API/form requests allowed per source IP and path. |
| `DDNS_ADMIN_RATE_LIMIT_PER_MINUTE` | `20` | Admin/operator requests allowed per source IP and path. |
| `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. |
| `DDNS_ADMIN_PATH` | `/admin` | Private operator UI path. Admin routes are hidden from OpenAPI and require Basic auth. |
| `DDNS_ADMIN_PATH` | `/admin` | Private operator UI path. Admin UI form routes stay hidden from OpenAPI and require Basic auth. |
| `DDNS_PUBLIC_BASE_URL` | `http://localhost:8080` | Public URL used for generated links, public examples, API docs, canonical URLs, and the default trusted host. |
| `DDNS_DASHBOARD_COOKIE_SECRET` | `DDNS_ADMIN_PASSWORD` fallback | HMAC signing secret for dashboard unlock cookies. Use a separate 32+ character random value in production. |
| `DDNS_REDACTED_ACCESS_LOG` | `0` | Enable KarlDNS request logs with bearer paths and update query strings redacted. |
| `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. |
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete generated hostnames that never received a router update after this many hours. |
| `DDNS_CLEANUP_PENDING_ACCOUNT_HOURS` | `24` | Delete generated setup intents that were never registered after this many hours. |
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete registered generated hostnames that never received a router update after this many hours. |
| `DDNS_CLEANUP_EVENT_HISTORY_HOURS` | `4320` | Delete old raw update/audit events after this many hours. Rollups and recent events remain visible. |
| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Store the cleanup task cadence in SQLite and schedule the next cleanup run at this interval. |
| `DDNS_DASHBOARD_UNLOCK_SECONDS` | `86400` | Default dashboard password unlock cookie lifetime. |
@@ -375,22 +390,21 @@ Backend layout:
| Module | Purpose |
| --- | --- |
| `router_dyndns/app.py` | FastAPI app factory, lifespan, middleware and router registration. |
| `router_dyndns/ddns.py` | Compatibility exports for existing imports and CLI users. |
| `router_dyndns/routers/` | Module-level `APIRouter` files for API, public web, management, admin, assets, and DynDNS update routes. |
| `router_dyndns/dependencies.py` | Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. |
| `router_dyndns/schemas.py` | Pydantic request and response models used by the JSON API. |
| `router_dyndns/web/` | HTML rendering, form parsing, and security-header/body-limit support code. |
| `router_dyndns/ddns_service.py` | Domain logic for account creation and IP updates. |
| `router_dyndns/store/` | SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. |
| `router_dyndns/ddns_store.py` | Compatibility export for `DdnsStore`. |
| `router_dyndns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
| `router_dyndns/ddns_dns.py` | Compatibility exports for DNS provider helpers. |
| `karldns/app.py` | FastAPI app factory, lifespan, middleware and router registration. |
| `karldns/settings.py` | Pydantic settings loaded from environment variables. |
| `karldns/models.py` | Shared domain models such as update results. |
| `karldns/routers/` | Module-level `APIRouter` files for API, public web, management, admin, assets, and DynDNS update routes. |
| `karldns/dependencies.py` | Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. |
| `karldns/schemas.py` | Pydantic request and response models used by the JSON API. |
| `karldns/web/` | Layout, reusable HTML components, scripts, CSS, form parsing, and security-header/body-limit support code. |
| `karldns/services/ddns.py` | Domain logic for account creation and IP updates. |
| `karldns/store/` | SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. |
| `karldns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
```bash
ruff check router_dyndns tests
ruff check karldns tests
pytest
python -m compileall router_dyndns
python -m compileall karldns
```
## SEO Keywords
+3 -3
View File
@@ -9,9 +9,9 @@ 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`.
- JavaScript-created classes in `karldns/web/scripts.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.
The app CSS in `karldns/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
@@ -34,7 +34,7 @@ Dry-run result on this worktree:
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`.
1. Run `python3 scripts/build-bootstrap-subset.py --write` to create `karldns/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.
+19
View File
@@ -0,0 +1,19 @@
"""Self-hosted DynDNS service for FRITZ!Box and compatible routers."""
from .app import make_app
from .dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
from .models import UpdateResult
from .settings import DdnsSettings
from .store import DdnsStore
__all__ = [
"DdnsSettings",
"DdnsStore",
"DnsConfigurationError",
"DnsProviderError",
"DnsReconciliationError",
"UpdateResult",
"delete_dns_records",
"make_app",
"publish_dns",
]
+12 -10
View File
@@ -11,11 +11,12 @@ from fastapi import FastAPI
from starlette.concurrency import run_in_threadpool
from starlette.middleware.trustedhost import TrustedHostMiddleware
from .ddns_dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_service import DdnsService
from .ddns_store import DdnsStore
from .dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
from .models import UpdateResult
from .routers import admin, api, assets, management, public, updates
from .services.ddns import DdnsService
from .settings import DdnsSettings
from .store import DdnsStore
from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware
__all__ = [
@@ -37,12 +38,12 @@ OPENAPI_TAGS = [
"description": "Create KarlDNS hostnames that can be used directly or as CNAME targets.",
},
{
"name": "updates",
"description": "Router-facing IP update endpoints for FRITZ!Box and compatible clients.",
"name": "router",
"description": "Router-facing registration and IP update endpoints for simple URL clients.",
},
{
"name": "management",
"description": "API management through private dashboard links. Dashboard passwords protect only the web dashboard, not these API routes.",
"name": "admin",
"description": "Operator API endpoints protected by the same admin HTTP Basic auth as the web admin console.",
},
]
@@ -84,8 +85,8 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
"for KarlDNS-managed hostnames through Cloudflare or RFC 2136."
),
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc",
docs_url=None,
redoc_url=None,
openapi_tags=OPENAPI_TAGS,
lifespan=lifespan,
)
@@ -135,6 +136,7 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None:
store.cleanup,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
settings.cleanup_pending_account_hours,
)
except Exception as exc:
LOGGER.exception("Scheduled cleanup failed")
+1
View File
@@ -0,0 +1 @@
:root{--bs-body-color:#000;--bs-body-bg:#fff;--bs-border-color:#e5e5e5;--bs-secondary-color:#737373}[data-bs-theme="dark"]{--bs-body-color:#f5f5f5;--bs-body-bg:#0f0f0f;--bs-border-color:rgba(255,255,255,.16);--bs-secondary-color:#a3a3a3}html{-webkit-text-size-adjust:100%}body{margin:0}button,input,select{margin:0;font:inherit}button,select{text-transform:none}button:not(:disabled),[type="button"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}a{color:inherit}img,svg{vertical-align:middle}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}pre{margin:0}.container{width:100%;margin-right:auto;margin-left:auto;padding-right:12px;padding-left:12px}.row{display:flex;flex-wrap:wrap;margin-right:calc(-.5 * var(--bs-gutter-x,0));margin-left:calc(-.5 * var(--bs-gutter-x,0));margin-top:calc(-1 * var(--bs-gutter-y,0))}.row>*{width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x,0) * .5);padding-left:calc(var(--bs-gutter-x,0) * .5);margin-top:var(--bs-gutter-y,0)}.col-6{flex:0 0 auto;width:50%}.col-12{flex:0 0 auto;width:100%}@media (min-width:768px){.col-md-6{flex:0 0 auto;width:50%}.align-items-md-center{align-items:center!important}.flex-md-row{flex-direction:row!important}}@media (min-width:992px){.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.333333%}.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:Line truncated
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-book-open"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>

After

Width:  |  Height:  |  Size: 458 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-briefcase"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
<rect width="20" height="14" x="2" y="6" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 399 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 315 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-clipboard"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
</svg>

After

Width:  |  Height:  |  Size: 434 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-code"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m16 18 6-6-6-6" />
<path d="m8 6-6 6 6 6" />
</svg>

After

Width:  |  Height:  |  Size: 341 B

@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-ellipsis-vertical"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>

After

Width:  |  Height:  |  Size: 400 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-moon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-newspaper"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 18h-5" />
<path d="M18 14h-8" />
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2" />
<rect width="8" height="4" x="10" y="6" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 496 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-shield-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>

After

Width:  |  Height:  |  Size: 498 B

+23
View File
@@ -0,0 +1,23 @@
<!-- @license lucide-static v1.16.0 - ISC -->
<svg
class="lucide lucide-sun"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 559 B

File renamed without changes.
File renamed without changes.
+7 -6
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import argparse
from .ddns import DdnsSettings, make_app
from .app import make_app
from .settings import DdnsSettings
def main() -> None:
@@ -11,7 +12,7 @@ def main() -> None:
render_url = subparsers.add_parser("render-fritz-url", help="Print a FRITZ!Box DynDNS update URL.")
render_url.add_argument("--base-url", required=True, help="Public KarlDNS URL.")
render_url.add_argument("--host", required=True, help="DNS hostname configured in FRITZ!Box.")
render_url.add_argument("--update-secret", required=True, help="Private router update secret.")
render_url.add_argument("--include-ipv6", action="store_true")
serve = subparsers.add_parser("serve", help="Run the KarlDNS web service.")
@@ -25,7 +26,7 @@ def main() -> None:
args = parser.parse_args()
if args.command == "render-fritz-url":
print(_render_fritz_url(args.base_url, args.host, args.include_ipv6))
print(_render_fritz_url(args.base_url, args.update_secret, args.include_ipv6))
elif args.command == "serve":
import uvicorn
@@ -37,12 +38,12 @@ def main() -> None:
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, update_secret: str, include_ipv6: bool) -> str:
base = base_url.rstrip("/")
url = f"{base}/nic/update?hostname=<domain>&myip=<ipaddr>&username=<username>&password=<pass>"
url = f"{base}/api/v1/router/update/{update_secret}?myip=<ipaddr>"
if include_ipv6:
url += "&myipv6=<ip6addr>"
return url.replace("<domain>", host)
return url
if __name__ == "__main__":
@@ -3,7 +3,7 @@ from __future__ import annotations
import os
from pathlib import Path
from .ddns_models import DdnsSettings
from .settings import DdnsSettings
def settings_from_env() -> DdnsSettings:
@@ -27,6 +27,7 @@ def settings_from_env() -> DdnsSettings:
require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"),
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
cleanup_pending_account_hours=_env_int("DDNS_CLEANUP_PENDING_ACCOUNT_HOURS", 24),
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080),
cleanup_event_history_hours=_env_int("DDNS_CLEANUP_EVENT_HISTORY_HOURS", 24 * 180),
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
@@ -3,14 +3,14 @@ from __future__ import annotations
import secrets
from typing import Annotated
from fastapi import Depends, Header, HTTPException, Query, Request
from fastapi import Depends, Header, HTTPException, Request
from fastapi.responses import PlainTextResponse
from .ddns_dns import DnsProviderError
from .ddns_models import DdnsSettings
from .ddns_security import credentials_from_basic_auth
from .ddns_service import BadIpError, DdnsService
from .ddns_store import DdnsStore
from .dns import DnsProviderError
from .security import credentials_from_basic_auth
from .services.ddns import BadIpError, DdnsService
from .settings import DdnsSettings
from .store import DdnsStore
def get_settings(request: Request) -> DdnsSettings:
@@ -30,20 +30,6 @@ StoreDep = Annotated[DdnsStore, Depends(get_store)]
ServiceDep = Annotated[DdnsService, Depends(get_service)]
def authenticate_update(
authorization: Annotated[str | None, Header()] = None,
username: str | None = Query(default=None),
password: str | None = Query(default=None),
pass_: str | None = Query(default=None, alias="pass"),
passwd: str | None = Query(default=None),
) -> tuple[str | None, str | None]:
supplied_user = username
supplied_token = password or pass_ or passwd
if authorization and authorization.lower().startswith("basic "):
supplied_user, supplied_token = credentials_from_basic_auth(authorization)
return supplied_user, supplied_token
def authenticate_admin(
request: Request,
authorization: Annotated[str | None, Header()] = None,
File renamed without changes.
@@ -1,6 +1,7 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings, UpdateResult
from ..models import UpdateResult
from ..settings import DdnsSettings
SUPPORTED_DNS_PROVIDERS = {"", "cloudflare", "rfc2136"}
@@ -5,7 +5,8 @@ import urllib.error
import urllib.parse
import urllib.request
from ..ddns_models import DdnsSettings, UpdateResult
from ..models import UpdateResult
from ..settings import DdnsSettings
from .base import DnsProviderError, DnsReconciliationError
@@ -1,6 +1,7 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings, UpdateResult
from ..models import UpdateResult
from ..settings import DdnsSettings
from .base import DnsProviderError
@@ -10,10 +10,9 @@ DASHBOARD_PASSWORD_CHANGED = "dashboard_password_changed"
DASHBOARD_PASSWORD_ENABLED = "dashboard_password_enabled"
DASHBOARD_PASSWORD_REMOVED = "dashboard_password_removed"
DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated"
DASHBOARD_ROUTER_CREDENTIALS_ROTATED = "dashboard_router_credentials_rotated"
DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted"
DASHBOARD_HOSTNAME_ACTIVATED = "dashboard_hostname_activated"
ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted"
ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated"
ADMIN_MANAGEMENT_URL_ROTATED = "admin_management_url_rotated"
ADMIN_ROUTER_CREDENTIALS_ROTATED = "admin_router_credentials_rotated"
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(slots=True)
class UpdateResult:
hostname: str
ipv4: str | None
ipv6: str | None
changed: bool
update_ipv4: bool = True
update_ipv6: bool = True
@property
def dyndns_response(self) -> str:
ip = self.ipv4 or self.ipv6 or "0.0.0.0"
return f"{'good' if self.changed else 'nochg'} {ip}"
File renamed without changes.
@@ -4,18 +4,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_security import client_ip, require_admin_csrf
from ..ddns_service import normalize_hostname
from ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
from ..dns import DnsProviderError
from ..event_statuses import (
ADMIN_HOSTNAME_DELETED,
ADMIN_MANAGEMENT_URL_ROTATED,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
ADMIN_UPDATE_URL_ROTATED,
)
from ..security import client_ip, require_admin_csrf
from ..services.ddns import normalize_hostname
from ..web.components import _copy_row
from ..web.forms import first_form_value, request_form
from ..web.templates import _copy_row
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100
@@ -24,8 +23,6 @@ ADMIN_JSON_OFFSET_CAP = 10_000
def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter()
records_path = f"{admin_path}/records"
events_path = f"{admin_path}/events"
def render_admin(
service: ServiceDep,
@@ -74,7 +71,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
},
)
@router.post(f"{admin_path}/accounts/delete", include_in_schema=False)
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
async def admin_delete_account(
request: Request,
settings: SettingsDep,
@@ -101,7 +98,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
)
return RedirectResponse(admin_path, status_code=303)
@router.post(f"{admin_path}/accounts/rotate", include_in_schema=False)
@router.post(f"{admin_path}/hostnames/rotate", include_in_schema=False)
async def admin_rotate_account(
request: Request,
settings: SettingsDep,
@@ -129,7 +126,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"router update URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Update URL changed", [_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), "Secret")]) if rotated else None
elif action == "management":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool(
@@ -138,31 +135,10 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
None,
None,
ADMIN_MANAGEMENT_URL_ROTATED,
"management URL rotated by admin",
"dashboard link rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.magic_management_url(rotated))]) if rotated else None
elif action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
"router credentials rotated by admin",
client_ip(request, settings),
)
if rotated:
notice = _admin_secret_panel(
"Router password changed",
[
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
_copy_row("Full update URL:", service.credentialed_update_url(rotated), "Secret"),
_copy_row("Benutzername (optional):", str(rotated["username"]), "Public"),
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
],
)
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")]) if rotated else None
else:
raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
@@ -182,6 +158,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
store.cleanup,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
settings.cleanup_pending_account_hours,
)
notice = _admin_secret_panel(
"Cleanup done",
@@ -193,42 +170,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
return HTMLResponse(page)
@router.get(records_path, include_in_schema=False)
def records(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
) -> dict[str, object]:
return {
"items": store.list_records_page(limit=limit, offset=offset),
"limit": limit,
"offset": offset,
"total": store.count_records(),
}
@router.get(events_path, include_in_schema=False)
def events(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
before: str | None = Query(None, max_length=64),
) -> dict[str, object]:
items = store.list_update_events_before(limit=limit, before=before) if before else store.list_update_events_page(limit=limit, offset=offset)
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
return {
"items": items,
"limit": limit,
"offset": offset,
"next_before": next_before,
"total": store.count_update_events(),
}
if admin_path == "/admin":
router.add_api_route("/records", records, methods=["GET"], include_in_schema=False)
router.add_api_route("/events", events, methods=["GET"], include_in_schema=False)
return router
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, StoreDep, authenticate_admin
from ..dns import DnsProviderError
from ..event_statuses import DASHBOARD_UPDATE_URL_ROTATED
from ..schemas import (
AdminDashboardResponse,
AdminEventsResponse,
AdminHostnamesResponse,
AdminRecordsResponse,
AdminTotals,
ErrorResponse,
HostnameCreateResponse,
HostnameRotateUpdateResponse,
HostnameStatusResponse,
_admin_hostname,
_admin_metrics,
_create_response,
_hostname_rotate_update_response,
_hostname_status,
)
from ..security import client_ip
from .path_params import ManagementSlugPath
router = APIRouter(prefix="/api/v1")
ADMIN_API_LIMIT_CAP = 500
ADMIN_API_OFFSET_CAP = 10_000
HOSTNAME_HISTORY_LIMIT_CAP = 100
@router.post(
"/hostnames",
response_model=HostnameCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a KarlDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
account = await run_in_threadpool(service.create_managed_account)
return _create_response(service, account)
@router.get(
"/hostnames/{dashboard_secret}",
response_model=HostnameStatusResponse,
summary="Read a hostname by private dashboard link",
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
tags=["hostnames"],
responses={404: {"model": ErrorResponse}},
)
async def read_hostname(
dashboard_secret: ManagementSlugPath,
store: StoreDep,
service: ServiceDep,
history_limit: int = Query(10, ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP),
) -> HostnameStatusResponse:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
account["management_slug"] = dashboard_secret
history = await run_in_threadpool(store.list_update_events_for_hostname, str(account["hostname"]), history_limit) if history_limit else []
return _hostname_status(service, account, history)
@router.post(
"/hostnames/{dashboard_secret}/rotate-update-url",
response_model=HostnameRotateUpdateResponse,
summary="Rotate a hostname update URL",
description="Uses the private dashboard secret. The old router update URL stops working immediately.",
tags=["hostnames"],
responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def rotate_hostname_update_url(
dashboard_secret: ManagementSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
) -> HostnameRotateUpdateResponse:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
if not account["accepted_at"]:
raise HTTPException(status_code=400, detail="register hostname before rotating update URL")
hostname = str(account["hostname"])
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
if not rotated:
raise HTTPException(status_code=404, detail="not found")
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_UPDATE_URL_ROTATED,
"router update URL rotated by API",
client_ip(request, service.settings),
)
return _hostname_rotate_update_response(service, rotated, dashboard_secret)
@router.delete(
"/hostnames/{dashboard_secret}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by private dashboard link",
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
tags=["hostnames"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "api")
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS delete failed. Hostname was not deleted.") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/admin/dashboard",
response_model=AdminDashboardResponse,
summary="Read operator dashboard summary",
description="Requires admin HTTP Basic auth. Returns the same high-level data shown in the operator console.",
tags=["admin"],
responses={401: {"model": ErrorResponse}},
)
async def read_admin(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(25, ge=1, le=ADMIN_API_LIMIT_CAP),
) -> AdminDashboardResponse:
metrics = await run_in_threadpool(store.get_service_metrics)
accounts = await run_in_threadpool(store.list_accounts, limit, 0)
cleanup_runs = await run_in_threadpool(store.list_cleanup_runs, limit, 0)
scheduled_tasks = await run_in_threadpool(store.list_scheduled_tasks)
events = await run_in_threadpool(store.list_update_events, limit, 0)
return AdminDashboardResponse(
totals=AdminTotals(
hostnames=await run_in_threadpool(store.count_accounts),
records=await run_in_threadpool(store.count_records),
update_events=await run_in_threadpool(store.count_update_events),
cleanup_runs=await run_in_threadpool(store.count_cleanup_runs),
),
metrics=_admin_metrics(metrics),
hostnames=[_admin_hostname(account) for account in accounts],
update_events=events,
cleanup_runs=cleanup_runs,
scheduled_tasks=scheduled_tasks,
limit=limit,
)
@router.get(
"/admin/hostnames",
response_model=AdminHostnamesResponse,
summary="List operator hostnames",
description="Requires admin HTTP Basic auth.",
tags=["admin"],
responses={401: {"model": ErrorResponse}},
)
async def list_admin_hostnames(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
) -> AdminHostnamesResponse:
accounts = await run_in_threadpool(store.list_accounts, limit, offset)
total = await run_in_threadpool(store.count_accounts)
return AdminHostnamesResponse(
items=[_admin_hostname(account) for account in accounts],
limit=limit,
offset=offset,
total=total,
)
@router.get(
"/admin/events",
response_model=AdminEventsResponse,
summary="List operator update history",
description="Requires admin HTTP Basic auth. Supports offset pagination or keyset pagination with before.",
tags=["admin"],
responses={401: {"model": ErrorResponse}},
)
async def list_admin_events(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
before: str | None = Query(None, max_length=64),
) -> AdminEventsResponse:
items = (
await run_in_threadpool(store.list_update_events_before, limit, before)
if before
else await run_in_threadpool(store.list_update_events_page, limit, offset)
)
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
total = await run_in_threadpool(store.count_update_events)
return AdminEventsResponse(items=items, limit=limit, offset=offset, next_before=next_before, total=total)
@router.get(
"/admin/records",
response_model=AdminRecordsResponse,
summary="List operator DNS records",
description="Requires admin HTTP Basic auth.",
tags=["admin"],
responses={401: {"model": ErrorResponse}},
)
async def list_admin_records(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset)
total = await run_in_threadpool(store.count_records)
return AdminRecordsResponse(items=items, limit=limit, offset=offset, total=total)
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse
from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS
from ..web.styles import ADMIN_CSS, BASE_CSS
ASSET_DIR = Path(__file__).parents[1] / "assets"
router = APIRouter(include_in_schema=False)
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
def _minify_css(css: str) -> str:
return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}")
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
@router.get("/favicon.ico")
def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@router.get("/bootstrap-subset.css")
def bootstrap_subset_css() -> FileResponse:
return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-16x16.png")
def favicon_16_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-32x32.png")
def favicon_32_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/apple-touch-icon.png")
def apple_touch_icon_png() -> FileResponse:
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.png")
def logo_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.webp")
def logo_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-nav.webp")
def logo_nav_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-192.png")
def logo_192_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-512.png")
def logo_512_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.png")
def social_preview_png() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.webp")
def social_preview_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/site.webmanifest")
def site_webmanifest() -> Response:
return Response(
"""
{
"name": "KarlDNS",
"short_name": "KarlDNS",
"description": "Private update links for routers.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"}
]
}
""".strip(),
media_type="application/manifest+json",
headers=ASSET_CACHE_HEADERS,
)
@router.get("/theme.js")
def theme_js() -> Response:
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/clipboard.js")
def clipboard_js() -> Response:
return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/admin-tables.js")
def admin_tables_js() -> Response:
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/app.css")
def app_css() -> Response:
return Response(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")
def install_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
status_code=302,
)
@router.get("/uninstall.sh")
def uninstall_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
status_code=302,
)
@router.get("/karldns-update.sh")
def karldns_update_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/karldns-update.sh",
status_code=302,
)
@@ -3,27 +3,33 @@ from __future__ import annotations
from typing import NoReturn
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..dns import DnsProviderError
from ..event_statuses import (
DASHBOARD_HOSTNAME_ACTIVATED,
DASHBOARD_HOSTNAME_DELETED,
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
DASHBOARD_UPDATE_URL_ROTATED,
)
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..services.ddns import BadIpError
from ..web.forms import first_form_value, request_form
from ..web.html_utils import trusted
from ..web.templates import _page
from ..web.templates_management import _render_management_page, _render_management_unlock_page
from ..web.layout import _page
from ..web.templates_management import (
_render_management_page,
_render_management_unlock_page,
_render_pending_management_page,
)
from . import management_passwords
from .management_shared import (
get_active_management_account,
get_active_management_account_async,
has_management_access,
locked_management_response,
management_account_is_accepted,
render_management_page_async,
)
from .path_params import ManagementSlugPath
@@ -33,16 +39,82 @@ router.include_router(management_passwords.router)
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
def manage_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
def manage_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
activated: bool = False,
) -> str:
account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account)
if not management_account_is_accepted(account):
return _render_pending_management_page(settings, management_slug, account, csrf_token)
if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token)
return _render_management_page(service, settings, management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None
return _render_management_page(service, settings, management_slug, account, csrf_token, message=message, request_ip=client_ip(request, settings))
@router.post("/m/{management_slug}/activate", include_in_schema=False)
async def activate_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RedirectResponse:
account = await get_active_management_account_async(store, management_slug)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if not management_account_is_accepted(account):
try:
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
except BadIpError:
return HTMLResponse(
_render_pending_management_page(
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"Registration needs a public source IP. Try again from your home connection or use the API with myip.",
),
status_code=400,
)
except DnsProviderError:
return HTMLResponse(
_render_pending_management_page(
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
"DNS registration failed. The hostname was not registered.",
),
status_code=500,
)
account = await run_in_threadpool(store.activate_account, hostname)
if not account:
return _not_found()
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_HOSTNAME_ACTIVATED,
"hostname registered",
client_ip(request, settings),
)
return RedirectResponse(f"/m/{management_slug}?activated=1", status_code=303)
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
async def rotate_magic_credentials(
async def rotate_dashboard_secrets(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
@@ -50,12 +122,18 @@ async def rotate_magic_credentials(
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not management_account_is_accepted(account):
return HTMLResponse(
_render_pending_management_page(settings, management_slug, account, dashboard_csrf_token(management_slug, account), "Register this hostname first."),
status_code=400,
)
if not has_management_access(request, settings, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
action = first_form_value(form, "action")
request_ip = client_ip(request, settings)
if action == "update":
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
if not rotated:
@@ -67,7 +145,7 @@ async def rotate_magic_credentials(
None,
DASHBOARD_UPDATE_URL_ROTATED,
"router update URL rotated",
client_ip(request, settings),
request_ip,
)
return await render_management_page_async(
service,
@@ -76,35 +154,13 @@ async def rotate_magic_credentials(
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Update URL changed. Update your router now.",
)
if action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
if not rotated:
return _not_found()
rotated["management_slug"] = management_slug
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
"router credentials rotated",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Router password changed. Copy it now.",
created_account=rotated,
request_ip=request_ip,
)
raise _bad_rotation_action()
@router.post("/m/{management_slug}/delete", include_in_schema=False)
async def delete_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
@@ -4,9 +4,9 @@ from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..web.forms import first_form_value, request_form
from ..web.templates_management import _render_management_unlock_page
from .management_shared import (
@@ -24,7 +24,7 @@ router = APIRouter()
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
async def unlock_dashboard(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not account.get("dashboard_password_hash"):
return RedirectResponse(f"/m/{management_slug}", status_code=303)
@@ -70,7 +70,14 @@ async def set_dashboard_password(
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.")
return await management_error_response_async(
service,
settings,
management_slug,
account,
"Current password is incorrect.",
client_ip(request, settings),
)
password = first_form_value(form, "dashboard_password")
confirm = first_form_value(form, "dashboard_password_confirm")
if len(password) < 8:
@@ -81,6 +88,7 @@ async def set_dashboard_password(
account,
dashboard_csrf_token(management_slug, account),
message="Password must be at least 8 characters.",
request_ip=client_ip(request, settings),
)
return HTMLResponse(page, status_code=400)
if password != confirm:
@@ -91,6 +99,7 @@ async def set_dashboard_password(
account,
dashboard_csrf_token(management_slug, account),
message="The passwords do not match.",
request_ip=client_ip(request, settings),
)
return HTMLResponse(page, status_code=400)
had_password = bool(account.get("dashboard_password_hash"))
@@ -131,7 +140,14 @@ async def remove_dashboard_password(
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.")
return await management_error_response_async(
service,
settings,
management_slug,
account,
"Current password is incorrect.",
client_ip(request, settings),
)
await run_in_threadpool(store.clear_dashboard_password, hostname)
await run_in_threadpool(
store.log_update_event,
@@ -4,16 +4,16 @@ from fastapi import HTTPException, Request, Response
from fastapi.responses import HTMLResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_models import DdnsSettings
from ..ddns_security import (
from ..security import (
DASHBOARD_UNLOCK_COOKIE,
dashboard_cookie_signing_secret,
dashboard_csrf_token,
sign_dashboard_unlock_cookie,
verify_dashboard_unlock_cookie,
)
from ..ddns_service import DdnsService
from ..ddns_store import DdnsStore
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from ..store import DdnsStore
from ..store.types import AccountRow
from ..web.templates_management import _render_management_page, _render_management_unlock_page
@@ -31,6 +31,10 @@ async def get_active_management_account_async(store: DdnsStore, management_slug:
return await run_in_threadpool(get_active_management_account, store, management_slug)
def management_account_is_accepted(account: ManagementAccount) -> bool:
return bool(account.get("accepted_at"))
def has_management_access(request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount) -> bool:
password_hash = account.get("dashboard_password_hash")
if not password_hash:
@@ -54,9 +58,18 @@ def management_error_response(
management_slug: str,
account: ManagementAccount,
message: str,
request_ip: str | None = None,
) -> HTMLResponse:
return HTMLResponse(
_render_management_page(service, settings, management_slug, account, dashboard_csrf_token(management_slug, account), message=message),
_render_management_page(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message=message,
request_ip=request_ip,
),
status_code=400,
)
@@ -67,8 +80,9 @@ async def management_error_response_async(
management_slug: str,
account: ManagementAccount,
message: str,
request_ip: str | None = None,
) -> HTMLResponse:
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message)
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message, request_ip)
async def render_management_page_async(
@@ -79,6 +93,7 @@ async def render_management_page_async(
csrf_token: str,
message: str | None = None,
created_account: dict[str, str] | None = None,
request_ip: str | None = None,
) -> str:
return await run_in_threadpool(
_render_management_page,
@@ -89,6 +104,7 @@ async def render_management_page_async(
csrf_token,
message,
created_account,
request_ip,
)
File renamed without changes.
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, SettingsDep
from ..security import client_ip
from ..web.forms import request_form
from ..web.templates_public import _render_api_docs_page, _render_privacy_page, _render_public_home
router = APIRouter()
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
def index(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
return _render_public_home(service, settings, None, None, client_ip(request, settings))
@router.post("/", response_class=HTMLResponse, include_in_schema=False)
async def create_hostname(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
await request_form(request, settings)
account = await run_in_threadpool(service.create_managed_account)
return _render_public_home(service, settings, account, None, client_ip(request, settings))
@router.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
def healthz() -> str:
return "ok"
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
def privacy(settings: SettingsDep) -> str:
return _render_privacy_page(settings)
@router.get("/docs", response_class=HTMLResponse, include_in_schema=False)
def api_docs(request: Request, settings: SettingsDep) -> str:
return _render_api_docs_page(settings, client_ip(request, 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: /api/",
"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")
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import json
from urllib.parse import parse_qs
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, StoreDep, apply_update_response
from ..dns import DnsProviderError
from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED
from ..security import client_ip
from ..services.ddns import BadIpError
from .path_params import ManagementSlugPath, UpdateSlugPath
router = APIRouter()
@router.get(
"/api/v1/router/register/{dashboard_secret}",
response_class=PlainTextResponse,
response_model=None,
operation_id="registerHostnameFromRouterGet",
tags=["router"],
)
async def register_hostname_get(
dashboard_secret: ManagementSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
@router.post(
"/api/v1/router/register/{dashboard_secret}",
response_class=PlainTextResponse,
response_model=None,
operation_id="registerHostnameFromRouterPost",
tags=["router"],
)
async def register_hostname_post(
dashboard_secret: ManagementSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
async def _register_hostname(
dashboard_secret: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> str | PlainTextResponse:
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
if not service.hostname_is_managed(hostname):
raise HTTPException(status_code=404, detail="not found")
if not account["accepted_at"]:
try:
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
except BadIpError as exc:
return PlainTextResponse(f"badip {exc}", status_code=400)
except DnsProviderError:
return PlainTextResponse("911 dns registration failed", status_code=500)
activated = await run_in_threadpool(store.activate_account, hostname)
if not activated:
raise HTTPException(status_code=404, detail="not found")
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_HOSTNAME_ACTIVATED,
"hostname registered",
client_ip(request, service.settings),
)
return "good registered"
@router.get(
"/api/v1/router/update/{update_slug}",
response_class=PlainTextResponse,
response_model=None,
operation_id="updateHostnameFromRouterGet",
tags=["router"],
)
async def update_hostname_get(
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
@router.post(
"/api/v1/router/update/{update_slug}",
response_class=PlainTextResponse,
response_model=None,
operation_id="updateHostnameFromRouterPost",
tags=["router"],
)
async def update_hostname_post(
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
async def _update_hostname(
update_slug: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> str | PlainTextResponse:
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
if not account or account["disabled"] or not account["accepted_at"]:
raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])):
raise HTTPException(status_code=404, detail="not found")
return await run_in_threadpool(apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr)
async def _ip_params_from_request(
request: Request,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> tuple[str | None, str | None, str | None, str | None]:
if request.method != "POST":
return myip, ipaddr, myipv6, ip6addr
body_values = await _post_values(request)
return (
myip or body_values.get("myip"),
ipaddr or body_values.get("ipaddr"),
myipv6 or body_values.get("myipv6"),
ip6addr or body_values.get("ip6addr"),
)
async def _post_values(request: Request) -> dict[str, str]:
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
body = await request.body()
if not body:
return {}
if content_type == "application/json":
try:
data = json.loads(body)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
return {str(key): str(value) for key, value in data.items() if isinstance(value, str)}
if content_type == "application/x-www-form-urlencoded":
try:
form_body = body.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {}
try:
parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
except ValueError:
return {}
return {key: values[0] for key, values in parsed.items() if values}
return {}
+203
View File
@@ -0,0 +1,203 @@
from pydantic import BaseModel, Field
from .services.ddns import DdnsService
class ErrorResponse(BaseModel):
detail: str = Field(examples=["not found"])
class HostnameCreateResponse(BaseModel):
hostname: str
status: str = Field(description="Lifecycle state. Newly generated hostnames start as pending until registered.")
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
registration_url: str = Field(description="Secret URL used to register the pending hostname after secrets are saved.")
class HostnameStatusResponse(BaseModel):
hostname: str
status: str
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
history: list["HostnameUpdateEvent"] = Field(default_factory=list)
class HostnameRotateUpdateResponse(BaseModel):
hostname: str
status: str
update_url: str = Field(description="Secret replacement update URL for router DynDNS updates.")
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
class HostnameUpdateEvent(BaseModel):
hostname: str
ipv4: str | None = None
ipv6: str | None = None
status: str
detail: str
created_at: str
count: int = 1
class AdminHostname(BaseModel):
hostname: str
status: str
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
created_at: str | None = None
accepted_at: str | None = None
disabled: bool = False
class AdminRecord(BaseModel):
hostname: str
ipv4: str | None = None
ipv6: str | None = None
updated_at: str
class AdminUpdateEvent(BaseModel):
hostname: str
ipv4: str | None = None
ipv6: str | None = None
status: str
detail: str
source_ip: str
created_at: str
count: int = 1
class AdminCleanupRun(BaseModel):
created_at: str
unused_accounts_deleted: int
class AdminScheduledTask(BaseModel):
name: str
interval_seconds: int
next_run_at: str
lease_owner: str | None = None
lease_expires_at: str | None = None
last_started_at: str | None = None
last_finished_at: str | None = None
last_status: str
last_error: str
updated_at: str
class AdminMetrics(BaseModel):
values: dict[str, float]
average_update_latency_ms: float
class AdminTotals(BaseModel):
hostnames: int
records: int
update_events: int
cleanup_runs: int
class AdminDashboardResponse(BaseModel):
totals: AdminTotals
metrics: AdminMetrics
hostnames: list[AdminHostname]
update_events: list[AdminUpdateEvent]
cleanup_runs: list[AdminCleanupRun]
scheduled_tasks: list[AdminScheduledTask]
limit: int
class AdminHostnamesResponse(BaseModel):
items: list[AdminHostname]
limit: int
offset: int
total: int
class AdminRecordsResponse(BaseModel):
items: list[AdminRecord]
limit: int
offset: int
total: int
class AdminEventsResponse(BaseModel):
items: list[AdminUpdateEvent]
limit: int
offset: int
next_before: str | None = None
total: int
def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameCreateResponse:
return HostnameCreateResponse(
hostname=account["hostname"],
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url(account),
dashboard_url=service.dashboard_url(account),
registration_url=service.registration_url(account),
)
def _hostname_status(
service: DdnsService,
account: dict[str, str | int | None],
history: list[dict[str, str | int | None]] | None = None,
) -> HostnameStatusResponse:
account_for_url = {"update_slug": str(account["update_slug"])}
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
return HostnameStatusResponse(
hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url(account_for_url),
dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "",
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
history=[_hostname_update_event(event) for event in history or []],
)
def _hostname_rotate_update_response(service: DdnsService, account: dict[str, str | int | None], dashboard_secret: str) -> HostnameRotateUpdateResponse:
return HostnameRotateUpdateResponse(
hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
dashboard_url=service.dashboard_url({"management_slug": dashboard_secret}),
)
def _hostname_update_event(row: dict[str, str | int | None]) -> HostnameUpdateEvent:
return HostnameUpdateEvent(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
status=str(row["status"]),
detail=str(row["detail"]),
created_at=str(row["created_at"]),
count=int(row.get("count") or 1),
)
def _admin_hostname(account: dict[str, str | int | None]) -> AdminHostname:
return AdminHostname(
hostname=str(account["hostname"]),
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
created_at=str(account["created_at"]) if account.get("created_at") else None,
accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None,
disabled=bool(account.get("disabled")),
)
def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics:
samples = metrics.get("update_latency_samples_total", 0)
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
return AdminMetrics(values=metrics, average_update_latency_ms=average_latency)
@@ -10,7 +10,7 @@ from ipaddress import ip_address, ip_network
from fastapi import HTTPException, Request
from .ddns_models import DdnsSettings
from .settings import DdnsSettings
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
@@ -153,18 +153,11 @@ def client_ip(request: Request, settings: DdnsSettings | None = None) -> str:
def is_rate_limited_path(path: str) -> bool:
limited_paths = {
"/magic",
"/accounts",
"/nic/update",
}
return path in limited_paths or path.startswith(("/u/", "/m/", "/api/v1/updates/")) or path.startswith(
("/api/v1/hostnames/", "/api/v1/management/")
)
return path == "/" or path.startswith(("/m/", "/api/v1/hostnames", "/api/v1/router/"))
def rate_limit_bucket(path: str) -> str:
for prefix in ("/u/", "/m/", "/api/v1/updates/", "/api/v1/management/"):
for prefix in ("/m/", "/api/v1/hostnames", "/api/v1/router/"):
if path.startswith(prefix):
return prefix.rstrip("/")
return path
File renamed without changes.
@@ -12,11 +12,12 @@ from dataclasses import dataclass
from fastapi import HTTPException, Request
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_security import client_ip
from .ddns_store import DdnsStore
from .event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED
from ..dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
from ..event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED
from ..models import UpdateResult
from ..security import client_ip
from ..settings import DdnsSettings
from ..store import DdnsStore
LOGGER = logging.getLogger(__name__)
@@ -39,12 +40,12 @@ class DdnsService:
self.store = store
self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
def create_managed_account(self, username: str | None = None) -> dict[str, str]:
def create_managed_account(self) -> dict[str, str]:
if not self.settings.hostname_suffix:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
for _ in range(5):
try:
return self.store.create_account(self.random_managed_hostname(), username)
return self.store.create_account(self.random_managed_hostname(), accepted=False)
except sqlite3.IntegrityError:
continue
raise HTTPException(status_code=500, detail="could not allocate hostname")
@@ -63,6 +64,58 @@ class DdnsService:
raise
self.store.delete_account(hostname)
def publish_initial_dns(
self,
request: Request,
hostname: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> None:
if not dns_backend_configured(self.settings):
return
source_ip = client_ip(request, self.settings)
source_fallback = source_ip if _is_global_ip(source_ip) else ""
try:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_fallback,
myip,
ipaddr,
myipv6,
ip6addr,
)
except BadIpError as exc:
raise BadIpError("registration needs a public source IP or myip/myipv6 parameter") from exc
lock = self._lock_for(hostname)
with lock:
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
if planned.changed:
try:
publish_dns(planned, self.settings)
except DnsReconciliationError as exc:
LOGGER.warning(
"Initial 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:
LOGGER.warning(
"Initial DNS publish failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
hostname,
planned.update_ipv4,
planned.update_ipv6,
type(exc).__name__,
)
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
raise
result = self.store.commit_update_result(planned)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "initial dns registered", source_ip)
def apply_update(
self,
request: Request,
@@ -145,24 +198,18 @@ class DdnsService:
def fritz_update_url(self, account: dict[str, str]) -> str:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["update_slug"])
return f"{base}/u/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
return f"{base}/api/v1/router/update/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
def credentialed_update_url(self, account: dict[str, str]) -> str:
base = self.settings.public_base_url.rstrip("/")
hostname = urllib.parse.quote(account["hostname"])
username = urllib.parse.quote(account["username"])
password = urllib.parse.quote(account["password"])
return (
f"{base}/nic/update?hostname={hostname}"
f"&username={username}&password={password}"
"&myip=<ipaddr>&myipv6=<ip6addr>"
)
def magic_management_url(self, account: dict[str, str]) -> str:
def dashboard_url(self, account: dict[str, str]) -> str:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"])
return f"{base}/m/{slug}"
def registration_url(self, account: dict[str, str]) -> str:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"])
return f"{base}/api/v1/router/register/{slug}"
def random_managed_hostname(self) -> str:
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
if not suffix:
@@ -213,6 +260,15 @@ def parse_update_ips(
return ipv4, ipv6, update_ipv4, update_ipv6
def _is_global_ip(value: str | None) -> bool:
if not value:
return False
try:
return ipaddress.ip_address(value.strip()).is_global
except ValueError:
return False
def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) -> str | None:
selected = None
for name, value in params:
@@ -2,7 +2,6 @@ from __future__ import annotations
import re
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -30,6 +29,7 @@ class DdnsSettings(BaseModel):
require_dns_provider: bool = Field(default=False)
cloudflare_api_token: str = Field(default="")
cloudflare_zone_id: str = Field(default="")
cleanup_pending_account_hours: int = Field(default=24, ge=1, le=24 * 30)
cleanup_unused_account_hours: int = Field(default=1080, ge=1, le=24 * 365)
cleanup_event_history_hours: int = Field(default=24 * 180, ge=1, le=24 * 3650)
cleanup_interval_seconds: int = Field(default=3600, ge=1, le=24 * 60 * 60)
@@ -60,7 +60,6 @@ class DdnsSettings(BaseModel):
if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path):
raise ValueError("admin_path may only contain URL-safe path characters")
reserved_paths = {
"/accounts",
"/admin.css",
"/admin-tables.js",
"/api",
@@ -70,14 +69,11 @@ class DdnsSettings(BaseModel):
"/favicon.ico",
"/healthz",
"/install.sh",
"/ip",
"/bootstrap-subset.css",
"/logo.png",
"/logo-nav.webp",
"/logo.webp",
"/m",
"/magic",
"/nic",
"/openapi.json",
"/privacy",
"/redoc",
@@ -87,7 +83,6 @@ class DdnsSettings(BaseModel):
"/social-preview.png",
"/social-preview.webp",
"/theme.js",
"/u",
"/uninstall.sh",
}
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
@@ -126,18 +121,3 @@ class DdnsSettings(BaseModel):
from .config import settings_from_env
return settings_from_env()
@dataclass(slots=True)
class UpdateResult:
hostname: str
ipv4: str | None
ipv6: str | None
changed: bool
update_ipv4: bool = True
update_ipv6: bool = True
@property
def dyndns_response(self) -> str:
ip = self.ipv4 or self.ipv6 or "0.0.0.0"
return f"{'good' if self.changed else 'nochg'} {ip}"
File renamed without changes.
@@ -1,44 +1,40 @@
from __future__ import annotations
import re
import secrets
from datetime import UTC, datetime
from ..ddns_security import hash_lookup_token, hash_secret, verify_secret
from ..security import hash_lookup_token, hash_secret, verify_secret
from .types import AccountRow
class AccountsMixin:
def create_account(self, hostname: str, username: str | None = None) -> AccountRow:
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
now = datetime.now(UTC).isoformat()
token = secrets.token_urlsafe(24)
update_slug = secrets.token_urlsafe(32)
management_slug = secrets.token_urlsafe(32)
username = username or _default_username(hostname)
token_hash = hash_secret(token)
management_hash = hash_lookup_token(management_slug)
accepted_at = now if accepted else None
with self.connect() as conn:
conn.execute(
"""
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
VALUES (?, ?, ?, ?, ?, ?, 0)
INSERT INTO accounts(hostname, update_slug, management_hash, created_at, accepted_at, disabled)
VALUES (?, ?, ?, ?, ?, 0)
""",
(hostname, username, token_hash, update_slug, management_hash, now),
(hostname, update_slug, management_hash, now, accepted_at),
)
return {
"hostname": hostname,
"username": username,
"password": token,
"update_slug": update_slug,
"management_slug": management_slug,
"accepted_at": accepted_at,
}
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
with self.connect() as conn:
rows = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
SELECT accounts.hostname, accounts.update_slug,
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname
@@ -57,8 +53,8 @@ class AccountsMixin:
with self.connect() as conn:
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
@@ -71,7 +67,7 @@ class AccountsMixin:
with self.connect() as conn:
account = conn.execute(
"""
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
SELECT hostname, update_slug, accepted_at, disabled FROM accounts
WHERE update_slug = ?
""",
(update_slug,),
@@ -82,8 +78,8 @@ class AccountsMixin:
with self.connect() as conn:
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ?
@@ -92,20 +88,14 @@ class AccountsMixin:
).fetchone()
return dict(account) if account else None
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool:
if not username or not token:
return False
def activate_account(self, hostname: str) -> AccountRow | None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
account = conn.execute(
"""
SELECT token_hash, disabled FROM accounts
WHERE hostname = ? AND username = ?
""",
(hostname, username),
).fetchone()
if not account or account["disabled"]:
return False
return verify_secret(token, account["token_hash"])
conn.execute(
"UPDATE accounts SET accepted_at = COALESCE(accepted_at, ?) WHERE hostname = ?",
(now, hostname),
)
return self.get_account_by_hostname(hostname)
def delete_account(self, hostname: str) -> None:
with self.connect() as conn:
@@ -128,16 +118,6 @@ class AccountsMixin:
account["management_slug"] = management_slug
return account
def rotate_password(self, hostname: str) -> AccountRow | None:
token = secrets.token_urlsafe(24)
token_hash = hash_secret(token)
with self.connect() as conn:
conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["password"] = token
return account
def set_dashboard_password(self, hostname: str, password: str) -> AccountRow | None:
password_hash = hash_secret(password)
with self.connect() as conn:
@@ -166,8 +146,3 @@ class AccountsMixin:
if not account or account["disabled"] or not account["dashboard_password_hash"]:
return False
return verify_secret(password, account["dashboard_password_hash"])
def _default_username(hostname: str) -> str:
label = hostname.split(".", 1)[0]
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
File renamed without changes.
@@ -118,20 +118,29 @@ class RateLimitCleanupMixin:
self,
unused_account_hours: int,
event_history_hours: int = 24 * 180,
pending_account_hours: int = 24,
) -> dict[str, int]:
self.flush_rate_limits()
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
pending_account_cutoff = (datetime.now(UTC) - timedelta(hours=pending_account_hours)).isoformat()
event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
with self.connect() as conn:
account_cursor = conn.execute(
"""
DELETE FROM accounts
WHERE created_at < ?
WHERE (
accepted_at IS NULL
AND created_at < ?
)
OR (
accepted_at IS NOT NULL
AND created_at < ?
AND NOT EXISTS (
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
)
)
""",
(unused_account_cutoff,),
(pending_account_cutoff, unused_account_cutoff),
)
rollup_cursor = conn.execute(
"DELETE FROM update_event_rollups WHERE last_seen_at < ?",
@@ -3,7 +3,7 @@ from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from ..ddns_models import UpdateResult
from ..models import UpdateResult
from .types import UpdateEventRow
File renamed without changes.
@@ -4,13 +4,13 @@ import secrets
import sqlite3
from pathlib import Path
from ..ddns_security import hash_lookup_token
from ..security import hash_lookup_token
BUSY_TIMEOUT_MS = 10_000
CACHE_SIZE_KIB = 20_000
MMAP_SIZE_BYTES = 256 * 1024 * 1024
WAL_AUTOCHECKPOINT_PAGES = 1_000
SCHEMA_VERSION = 1
SCHEMA_VERSION = 3
def connect_database(database_path: Path) -> sqlite3.Connection:
@@ -34,6 +34,7 @@ def configure_connection(conn: sqlite3.Connection) -> None:
def initialize_schema(conn: sqlite3.Connection) -> None:
configure_connection(conn)
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
"""
@@ -143,12 +144,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
"""
CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE,
dashboard_password_hash TEXT,
created_at TEXT NOT NULL,
accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
@@ -156,6 +156,9 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
_ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT")
_ensure_column(conn, "accounts", "dashboard_password_hash", "TEXT")
_ensure_column(conn, "accounts", "accepted_at", "TEXT")
if schema_version < 2:
conn.execute("UPDATE accounts SET accepted_at = created_at WHERE accepted_at IS NULL")
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
@@ -163,11 +166,12 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
"UPDATE accounts SET management_hash = ? WHERE hostname = ?",
(hash_lookup_token(secrets.token_urlsafe(32)), row[0]),
)
_migrate_accounts_schema(conn)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_accepted_at ON accounts(accepted_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_hostname_username ON accounts(hostname, username)")
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
if schema_version < SCHEMA_VERSION:
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.execute("PRAGMA optimize")
@@ -178,6 +182,39 @@ def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
columns = {row[1] for row in conn.execute("PRAGMA table_info(accounts)")}
if "username" not in columns and "token_hash" not in columns:
return
conn.execute("DROP INDEX IF EXISTS idx_accounts_update_slug")
conn.execute("DROP INDEX IF EXISTS idx_accounts_management_hash")
conn.execute("DROP INDEX IF EXISTS idx_accounts_accepted_at")
conn.execute("DROP INDEX IF EXISTS idx_accounts_created_at")
conn.execute("DROP INDEX IF EXISTS idx_accounts_hostname_username")
conn.execute(
"""
CREATE TABLE accounts_new (
hostname TEXT PRIMARY KEY,
update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE,
dashboard_password_hash TEXT,
created_at TEXT NOT NULL,
accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled)
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
FROM accounts
"""
)
conn.execute("DROP TABLE accounts")
conn.execute("ALTER TABLE accounts_new RENAME TO accounts")
def _migrate_cleanup_runs_schema(conn: sqlite3.Connection) -> None:
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
if columns <= {"id", "created_at", "unused_accounts_deleted"}:
@@ -5,17 +5,15 @@ from typing import TypedDict
class AccountRow(TypedDict, total=False):
hostname: str
username: str
token_hash: str
update_slug: str
management_slug: str
dashboard_password_hash: str | None
created_at: str
accepted_at: str | None
disabled: int
ipv4: str | None
ipv6: str | None
updated_at: str | None
password: str
class UpdateEventRow(TypedDict, total=False):
View File
Whitespace-only changes.
+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
from ipaddress import ip_address
from .html_utils import SafeHtml, attr, escape, trusted
from .icons import _svg_icon
_EVENT_LABELS = {
"admin_hostname_deleted": "hostname deleted by admin",
"admin_management_url_rotated": "dashboard link rotated by admin",
"admin_update_url_rotated": "update URL rotated by admin",
"dashboard_hostname_activated": "hostname registered",
"dashboard_hostname_deleted": "hostname deleted",
"dashboard_password_changed": "dashboard password changed",
"dashboard_password_enabled": "dashboard password enabled",
"dashboard_password_removed": "dashboard password removed",
"dashboard_update_url_rotated": "update URL rotated",
"dns_delete_failed": "DNS delete failed",
"dns_failed": "DNS update failed",
"dns_reconcile_failed": "DNS reconciliation failed",
"unchanged": "unchanged",
"updated": "updated",
}
def _event_label(status: object) -> str:
value = str(status or "-")
return _EVENT_LABELS.get(value, value.replace("_", " "))
def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtml:
safe_label = escape(label)
safe_value = escape(value)
badge = ""
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
if sensitivity:
safe_sensitivity = escape(sensitivity)
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
value_field = (
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
if is_secret
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
)
reveal_button = (
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>'
if is_secret
else ""
)
return trusted(
f"""
<div class="input-group copy-field">
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
{value_field}
{reveal_button}
<button type="button" class="btn btn-outline-secondary copy-button" data-copy="{safe_value}" aria-label="Copy {safe_label}">
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button>
</div>
"""
)
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
safe_style = attr(style)
return trusted(
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_icon_button(label: str, value: str) -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
return trusted(
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
safe_label = escape(label)
buttons = []
for item_label, item_value in items:
buttons.append(
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
)
return trusted(
f"""
<details class="copy-menu">
<summary aria-label="{safe_label}" title="{safe_label}">
{_svg_icon("more", "more-icon")}
</summary>
<div class="copy-menu-popover" role="menu">
{''.join(buttons)}
</div>
</details>
"""
)
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None) -> SafeHtml:
safe_class = f" {attr(class_name)}" if class_name else ""
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
return trusted(
f"""
<div class="terminal-card code-window{safe_class}">
{_copy_icon_button(copy_label, copy_value)}
{code_lines}
</div>
"""
)
def _code_copy_value(commands: str) -> str:
lines = commands.splitlines()
prompt_lines = [line[2:] for line in lines if line.startswith("$ ")]
if prompt_lines:
return "\n".join(prompt_lines)
return "\n".join(line[2:] if line.startswith("$ ") else line for line in lines)
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
ipv4 = "203.0.113.10"
ipv6 = "2001:db8::10"
if not request_ip:
return ipv4, ipv6
try:
parsed = ip_address(request_ip.strip())
except ValueError:
return ipv4, ipv6
if not parsed.is_global:
return ipv4, ipv6
if parsed.version == 4:
return str(parsed), ipv6
return ipv4, str(parsed)
def _manual_update_commands(update_url: str, request_ip: str | None = None) -> str:
update_base = update_url.split("?", 1)[0]
ipv4, ipv6 = _example_update_ips(request_ip)
return "\n".join(
[
f"curl -sS '{update_base}?myip={ipv4}'",
f"curl -sS '{update_base}?myipv6={ipv6}'",
f"curl -sS '{update_base}' # use request IP",
]
)
def _manual_update_panel(commands: str) -> SafeHtml:
return trusted(
f"""
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<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>
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
"""
)
def _fritzbox_fields(
update_url: str,
hostname: str,
management_url: str | None = None,
) -> str:
lines = [
f"Update-URL: {update_url}",
f"Domainnamen: {hostname}",
f"CNAME target: {hostname}",
]
if management_url:
lines.append(f"Private dashboard: {management_url}")
return "\n".join(lines)
def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
secret_items = "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
return trusted(
f"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Hostname and CNAME target can be shared.
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
)
def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the private URLs now. Newly created or rotated secrets are shown only once.
</div>
"""
)
def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
@@ -4,7 +4,7 @@ import urllib.parse
from fastapi import HTTPException, Request
from ..ddns_models import DdnsSettings
from ..settings import DdnsSettings
MAX_FORM_FIELDS = 100
File renamed without changes.
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import re
from functools import cache
from pathlib import Path
from .html_utils import SafeHtml, attr, trusted
_ICON_DIR = Path(__file__).parents[1] / "assets" / "lucide"
_ICON_FILES = {
"blog": "newspaper",
"business": "briefcase",
"docs": "book-open",
"github": "code",
"more": "ellipsis-vertical",
"privacy": "shield-check",
}
def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
path = _lucide_body(name)
safe_class = attr(f"lucide-icon {css_class}")
return trusted(
f'<svg class="{safe_class}" aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24" '
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{path}</svg>'
)
@cache
def _lucide_body(name: str) -> str:
file_stem = _ICON_FILES.get(name, name)
raw = (_ICON_DIR / f"{file_stem}.svg").read_text(encoding="utf-8")
match = re.search(r"<svg\b[^>]*>(.*?)</svg>", raw, flags=re.DOTALL)
if not match:
raise ValueError(f"Invalid Lucide SVG: {file_stem}")
return re.sub(r"\s+", " ", match.group(1).strip())
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import json
from .html_utils import SafeHtml, attr, escape, trusted
from .icons import _svg_icon
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
if label:
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">API</a>')
theme_toggle = f"""
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
{_svg_icon("moon", "theme-icon theme-icon-moon")}
{_svg_icon("sun", "theme-icon theme-icon-sun")}
</button>
"""
return trusted(
f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
{links}
{account_html}
{theme_toggle}
</div>
</div>
</nav>
"""
)
def _site_footer() -> SafeHtml:
return trusted(
f"""
<footer class="site-footer border-top">
<div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
<nav class="footer-links" aria-label="Footer links">
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
{_svg_icon("github", "footer-icon")}
<span>GitHub</span>
</a>
<a class="footer-link" href="https://karl.fail" rel="noopener">
{_svg_icon("blog", "footer-icon")}
<span>karl.fail</span>
</a>
<a class="footer-link" href="https://karlcom.de" rel="noopener">
{_svg_icon("business", "footer-icon")}
<span>karlcom.de</span>
</a>
<a class="footer-link" href="/docs">
{_svg_icon("docs", "footer-icon")}
<span>API</span>
</a>
<a class="footer-link" href="/privacy">
{_svg_icon("privacy", "footer-icon")}
<span>Legal & privacy</span>
</a>
</nav>
</div>
</footer>
"""
)
def _page(
title: str,
body: SafeHtml,
extra_styles: SafeHtml | None = None,
extra_scripts: SafeHtml | None = None,
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
preload_logo: bool = False,
canonical_url: str | None = None,
image_url: str | None = None,
image_alt: str | None = None,
structured_data: dict[str, object] | None = None,
) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("")
extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or ""
social_image = image_url or ""
social_html = trusted("")
if social_url:
social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title
social_image_html = f"""
<meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
""" if social_image else ""
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(description)}">
<meta property="og:url" content="{attr(social_url)}">
{social_image_html}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
structured_data_html = trusted("")
if structured_data:
structured_data_html = trusted(
'<script type="application/ld+json">'
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
+ "</script>"
)
return f"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<meta name="description" content="{attr(description)}">
{canonical_html}
{social_html}
<title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
{preload_logo_html}
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=3" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=12" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
</head>
<body>{body}{_site_footer()}</body>
</html>
"""
@@ -1,16 +1,5 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse
from ..web.styles import ADMIN_CSS, BASE_CSS
ASSET_DIR = Path(__file__).parents[1] / "assets"
router = APIRouter(include_in_schema=False)
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
THEME_JS = """
(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
@@ -201,123 +190,3 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
render();
});
""".strip()
def _minify_css(css: str) -> str:
return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}")
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
@router.get("/favicon.ico")
def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@router.get("/bootstrap-subset.css")
def bootstrap_subset_css() -> FileResponse:
return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-16x16.png")
def favicon_16_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-32x32.png")
def favicon_32_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/apple-touch-icon.png")
def apple_touch_icon_png() -> FileResponse:
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.png")
def logo_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.webp")
def logo_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-nav.webp")
def logo_nav_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-192.png")
def logo_192_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-512.png")
def logo_512_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.png")
def social_preview_png() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.webp")
def social_preview_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/site.webmanifest")
def site_webmanifest() -> Response:
return Response(
"""
{
"name": "KarlDNS",
"short_name": "KarlDNS",
"description": "Private update links for routers.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"}
]
}
""".strip(),
media_type="application/manifest+json",
headers=ASSET_CACHE_HEADERS,
)
@router.get("/theme.js")
def theme_js() -> Response:
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/clipboard.js")
def clipboard_js() -> Response:
return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/admin-tables.js")
def admin_tables_js() -> Response:
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/app.css")
def app_css() -> Response:
return Response(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")
def install_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
status_code=302,
)
@router.get("/uninstall.sh")
def uninstall_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
status_code=302,
)
@@ -8,11 +8,11 @@ from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..ddns_models import DdnsSettings
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..ddns_store import DdnsStore
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..settings import DdnsSettings
from ..store import DdnsStore
ACCESS_LOGGER = logging.getLogger("router_dyndns.access")
ACCESS_LOGGER = logging.getLogger("karldns.access")
class RequestBodyTooLargeError(Exception):
@@ -98,45 +98,33 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
admin_path = settings.admin_path.rstrip("/")
admin_records = f"{admin_path}/records"
admin_events = f"{admin_path}/events"
legacy_admin_api = settings.admin_path == "/admin" and path in {"/records", "/events"}
return path == admin_path or path.startswith(f"{admin_path}/") or path in {admin_records, admin_events} or legacy_admin_api
return path == admin_path or path.startswith(f"{admin_path}/") or path.startswith("/api/v1/admin/")
def is_secret_response(request: Request) -> bool:
path = request.url.path
settings = request.app.state.settings
if is_admin_path(path, settings) or path.startswith(("/m/", "/u/")):
if is_admin_path(path, settings) or path.startswith("/m/"):
return True
if path.startswith("/api/v1/hostnames/"):
return request.method in {"POST", "DELETE"}
return (request.method == "POST" and path in {"/magic", "/accounts"}) or path == "/nic/update"
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
return True
if path.startswith("/api/v1/admin/"):
return True
return request.method == "POST" and path == "/"
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"}:
if path in {"/openapi.json", "/healthz"}:
return True
if path.startswith(("/api/", "/m/", "/u/", "/nic/")):
if path.startswith(("/api/", "/m/")):
return True
return is_admin_path(path, request.app.state.settings)
def content_security_policy(path: str) -> str:
if path in {"/docs", "/redoc", "/openapi.json"}:
return (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https://fastapi.tiangolo.com; "
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
return (
"default-src 'self'; "
@@ -152,14 +140,12 @@ def content_security_policy(path: str) -> str:
def redacted_access_path(path: str) -> str:
secret_prefixes = {
"/u": "/u/<secret>",
"/m": "/m/<secret>",
"/api/v1/updates": "/api/v1/updates/<secret>",
"/api/v1/management": "/api/v1/management/<secret>",
"/api/v1/hostnames": "/api/v1/hostnames/<secret>",
"/api/v1/router/register": "/api/v1/router/register/<secret>",
"/api/v1/router/update": "/api/v1/router/update/<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
@@ -163,11 +163,41 @@ code {
.theme-toggle {
align-items: center;
aspect-ratio: 1;
appearance: none;
background: transparent;
border: 0;
border-radius: 999px;
color: var(--rp-muted);
cursor: pointer;
display: inline-flex;
flex: 0 0 auto;
height: 30px;
justify-content: center;
min-height: 40px;
min-width: 40px;
padding: 0;
width: 30px;
}
.theme-toggle:hover {
background: var(--rp-surface-alt);
color: var(--rp-ink);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--rp-focus-ring);
outline-offset: 2px;
}
.theme-toggle svg {
height: 17px;
width: 17px;
}
.theme-toggle:active {
transform: scale(0.96);
}
.theme-toggle::-moz-focus-inner {
border: 0;
padding: 0;
}
@@ -224,6 +254,10 @@ code {
background: var(--rp-canvas);
}
.section-compact {
padding: 56px 0;
}
.section + .section {
border-top: 0;
}
@@ -327,6 +361,94 @@ h3 {
font: 400 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.hero-action-form {
display: inline-flex;
}
.simple-steps {
display: grid;
gap: 12px;
max-width: 620px;
margin: 28px auto 0;
padding: 0;
color: var(--rp-muted);
font-size: 15px;
line-height: 1.5;
list-style: none;
}
.simple-steps li {
margin: 0;
}
.simple-steps strong {
color: var(--rp-ink);
font-weight: 500;
}
.api-endpoints {
text-align: left;
}
.api-endpoints .simple-steps {
margin-left: 0;
margin-right: 0;
}
.flow-steps {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 22px;
max-width: 760px;
margin: 32px auto 0;
padding: 0;
list-style: none;
text-align: center;
}
.flow-steps li {
position: relative;
min-width: 0;
color: var(--rp-muted);
font-size: 14px;
line-height: 1.45;
}
.flow-steps li + li::before {
content: "";
position: absolute;
top: 11px;
left: -22px;
width: 22px;
height: 1px;
background: var(--rp-line);
}
.flow-steps strong {
display: block;
margin-bottom: 5px;
color: var(--rp-ink);
font-size: 15px;
font-weight: 500;
}
.flow-steps span {
display: block;
max-width: 140px;
margin: 0 auto;
}
.installer-commands {
display: grid;
gap: 28px;
margin-top: 28px;
}
.installer-command {
display: grid;
gap: 0;
}
.card {
background: var(--rp-surface);
border: 1px solid var(--rp-line);
@@ -582,6 +704,7 @@ select:focus-visible {
color: var(--rp-terminal-ink);
background: var(--rp-terminal);
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
text-align: left;
}
.code-window {
@@ -752,6 +875,10 @@ select:focus-visible {
padding: 64px 0;
}
.section-compact {
padding: 48px 0;
}
.hero-band {
min-height: auto;
}
@@ -761,6 +888,17 @@ select:focus-visible {
}
}
@media (max-width: 720px) {
.flow-steps {
grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: 440px;
}
.flow-steps li + li::before {
display: none;
}
}
@media (max-width: 560px) {
.container,
.container.narrow,
@@ -797,10 +935,26 @@ select:focus-visible {
}
.nav-label {
max-width: 100%;
display: none;
}
.flow-steps {
grid-template-columns: 1fr;
gap: 18px;
max-width: 280px;
margin-top: 28px;
}
.flow-steps li + li::before {
display: none;
}
.flow-steps span {
max-width: 240px;
}
.hero-band .btn,
.hero-action-form,
.card button,
.danger-section form,
.danger-section button {
@@ -835,6 +989,13 @@ select:focus-visible {
border-radius: 0;
}
.copy-field code.form-control {
flex: 1 1 auto;
width: 100%;
overflow: visible;
text-overflow: clip;
}
.copy-field .btn {
width: 100%;
border-radius: 0;
@@ -2,11 +2,11 @@ from __future__ import annotations
from urllib.parse import urlencode
from ..ddns_models import DdnsSettings
from ..ddns_security import admin_csrf_token
from ..ddns_service import DdnsService
from ..security import admin_csrf_token
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import _page, _top_nav
from .layout import _page, _top_nav
from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
@@ -45,8 +45,6 @@ def _render_admin_page(
csrf = attr(admin_csrf_token(settings))
admin_path = settings.admin_path
safe_admin_path = attr(admin_path)
safe_records_path = attr(f"{admin_path}/records")
safe_events_path = attr(f"{admin_path}/events")
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
"""
@@ -63,7 +61,7 @@ def _render_admin_page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_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>'))}
{_top_nav("Operator")}
<section class="section admin-heading">
<div class="container">
<p class="eyebrow">KarlDNS</p>
@@ -102,7 +100,7 @@ def _render_admin_page(
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
<tr><th scope="col">Hostname</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>
<tr><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
@@ -1,29 +1,29 @@
from __future__ import annotations
from .components import _event_label
from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
hostname = escape(account["hostname"])
username = escape(account["username"])
status = "Active" if account.get("accepted_at") else "Pending"
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
safe_admin_path = attr(admin_path)
return f"""
<tr>
<td>{hostname}</td><td>{username}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
<td>{hostname}</td><td>{escape(status)}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/rotate" class="d-flex gap-2 flex-wrap">
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="d-flex gap-2 flex-wrap">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Create a new router password" aria-label="New password for {hostname}">New password</button>
</form>
</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/delete">
<form method="post" action="{safe_admin_path}/hostnames/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button>
@@ -69,7 +69,7 @@ def _admin_table_pagination() -> str:
def _event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
hostname = escape(event.get("hostname") or "-")
status = escape(event.get("status") or "-")
status = escape(_event_label(event.get("status")))
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
@@ -1,20 +1,19 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import (
_copy_menu,
_copy_row,
_event_label,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_page,
_secret_notice,
_top_nav,
)
from .templates_public_sections import _credentials_panel
from .html_utils import SafeHtml, attr, escape, trusted
from .layout import _page, _top_nav
from .templates_public_sections import _created_hostname_panel
def _render_management_page(
@@ -25,28 +24,26 @@ def _render_management_page(
csrf_token: str,
message: str | None = None,
created_account: dict[str, str] | None = None,
request_ip: str | None = None,
) -> str:
account_for_url = {
"hostname": str(account["hostname"]),
"username": str(account["username"]),
"password": "",
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
hostname = escape(account["hostname"])
username = escape(account["username"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated")
fritzbox_fields = _fritzbox_fields(update_url, hostname)
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
manual_commands = _manual_update_commands(update_url)
manual_commands = _manual_update_commands(update_url, request_ip)
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)) or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
message_html = _management_message(message)
rotated_credentials = _credentials_panel(service, created_account)
rotated_links = _created_hostname_panel(service, created_account, request_ip)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
return _page(
"KarlDNS management",
@@ -62,15 +59,14 @@ def _render_management_page(
</div>
</section>
{message_html}
{rotated_credentials}
{rotated_links}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<h2>Router update settings</h2>
<p class="section-copy">Use the Update-URL if your router has one URL field. Username and password are only for routers that require them. Optional: point another DNS name at this hostname with a CNAME.</p>
{_one_time_secret_notice()}
<p class="section-copy">Use this Update-URL in your router or script. Optional: point another DNS name at this hostname with a CNAME.</p>
{_secret_notice()}
<div class="copy-actions mt-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])}
@@ -80,16 +76,14 @@ def _render_management_page(
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
</div>
</div>
{_manual_update_panel(manual_commands)}
<div class="card card-body gap-3 mt-4">
<div>
<p class="eyebrow">Actions</p>
<h3>Replace router secrets</h3>
<p class="section-copy">Create a new update URL or router password. Update your router after changing either one.</p>
<h3>Replace router update URL</h3>
<p class="section-copy">Create a new update URL. Update your router after changing it.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="/m/{attr(management_slug)}/rotate">
@@ -97,11 +91,6 @@ def _render_management_page(
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
</form>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="password">
<button class="btn btn-outline-primary rounded-pill" type="submit">New password</button>
</form>
</div>
</div>
</div>
@@ -159,6 +148,44 @@ def _render_management_page(
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
)
def _render_pending_management_page(
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
) -> str:
hostname = escape(account["hostname"])
message_html = _management_message(message)
cleanup_hours = settings.cleanup_pending_account_hours
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
return _page(
"Register hostname",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Pending setup</p>
<h1>{hostname}</h1>
<p class="lead">Register this hostname after you have copied the private values. Unregistered setups are cleaned up after {escape(cleanup_text)}.</p>
<form method="post" action="/m/{attr(management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
</div>
</section>
{message_html}
<section class="section section-compact">
<div class="container narrow text-center">
<p class="section-copy mx-auto">Lost the first page? Create a new hostname. Private URLs are shown only once.</p>
</div>
</section>
</main>
"""),
)
def _management_message(message: str | None) -> SafeHtml:
if not message:
return trusted("")
@@ -273,7 +300,7 @@ def _render_management_unlock_page(
def _user_event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
status = escape(event.get("status") or "-")
status = escape(_event_label(event.get("status")))
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import _code_window, _example_update_ips, _message_band
from .html_utils import escape, trusted
from .layout import _page, _top_nav
from .templates_public_sections import _created_hostname_panel, _linux_installer_section
def _render_public_home(
service: DdnsService,
settings: DdnsSettings,
created_account: dict[str, str] | None,
message: str | None,
request_ip: str | None = None,
) -> str:
base_url = settings.public_base_url.rstrip("/")
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
suffix = escape(hostname_suffix)
demo_update_url = escape(f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>")
example_ipv4, _ = _example_update_ips(request_ip)
create_demo_command = f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'"
create_demo = f"{create_demo_command}\ngood {example_ipv4}"
manual_demo = "\n".join(
[
"# explicit IPv4",
f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'",
"# source IP fallback",
f"curl -sS '{base_url}/api/v1/router/update/<secret>'",
]
)
created_html = _created_hostname_panel(service, created_account, request_ip)
message_html = _message_band(message) if message else ""
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
return _page(
"KarlDNS",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band">
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
<h1>Dynamic DNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<form method="post" action="/" class="hero-action-form" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
</div>
</div>
</section>
{message_html}
{created_html}
<section class="section section-compact">
<div class="container narrow text-center">
<p class="eyebrow">How it works</p>
<h2>No account needed.</h2>
<p class="section-copy mx-auto">Create a hostname, save the private values, register it, done.</p>
<ol class="flow-steps" aria-label="KarlDNS setup flow">
<li><strong>Create</strong><span>Random hostname and private links.</span></li>
<li><strong>Save</strong><span>Copy the secrets while they are shown.</span></li>
<li><strong>Register</strong><span>Activate the hostname when you are ready.</span></li>
<li><strong>Update</strong><span>Router, cron, or CNAME can use it.</span></li>
</ol>
{_code_window(create_demo, copy_label="update example", class_name="mt-4", copy_value=create_demo_command)}
</div>
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from a router or script.</h2>
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
{_code_window(manual_demo, copy_label="manual update examples", class_name="text-start mt-4")}
</div>
</section>
{_linux_installer_section(base_url)}
</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",
"CNAME target for external DNS names",
"Linux cron updater",
],
},
)
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, IP history, and cleanup behavior."
return _page(
"Legal and Privacy",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Legal and privacy</p>
<h1>Privacy basics.</h1>
<p class="lead">KarlDNS stores only the data needed to keep your hostname updated.</p>
</div>
</section>
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores hostnames, protected dashboard tokens, private update links, recent IP history, timestamps, and service events.</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Dashboard links, registration URLs, and update URLs are secrets. Anyone with them may update or manage the hostname. Dashboard lookup values are not stored as readable text.</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.</p>
</div>
<div>
<h2>Contact</h2>
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
</div>
</div>
</section>
</main>
"""),
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}/",
},
},
)
def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str:
base_url = settings.public_base_url.rstrip("/")
example_ipv4, _ = _example_update_ips(request_ip)
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/<dashboard-secret>?myip={example_ipv4}'"
update_example = f"curl -sS '{base_url}/api/v1/router/update/<update-secret>?myip={example_ipv4}'"
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<dashboard-secret>?history_limit=10'"
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<dashboard-secret>/rotate-update-url"
schema_example = f"curl -sS {base_url}/openapi.json"
admin_dashboard_example = f"curl -sS -u admin:<admin-password> {base_url}/api/v1/admin/dashboard"
admin_history_example = f"curl -sS -u admin:<admin-password> '{base_url}/api/v1/admin/events?limit=25'"
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
return _page(
"KarlDNS API",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">API</p>
<h1>Small API surface.</h1>
<p class="lead">Three surfaces: admin API, user dashboard data, and router registration/update calls.</p>
</div>
</section>
<section class="section section-compact">
<div class="container narrow d-grid gap-4">
<div class="api-endpoints">
<h2>Endpoints</h2>
<ol class="simple-steps text-start">
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and one-time secrets. The response includes <code>registration_url</code>.</li>
<li><strong>GET or POST /api/v1/router/register/&lt;secret&gt;.</strong> Register the hostname after secrets are saved. Uses the request IP, or <code>myip</code>/<code>myipv6</code>, for the initial DNS record.</li>
<li><strong>GET or POST /api/v1/router/update/&lt;secret&gt;.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
<li><strong>GET /api/v1/hostnames/&lt;secret&gt;.</strong> Read hostname status and recent history.</li>
<li><strong>POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li>
<li><strong>DELETE /api/v1/hostnames/&lt;secret&gt;.</strong> Delete hostname and DNS records.</li>
<li><strong>GET /api/v1/admin/dashboard.</strong> Read operator summary, metrics, recent hostnames, cleanup state, and recent update history with admin Basic auth.</li>
<li><strong>GET /api/v1/admin/events.</strong> Read paginated update history with admin Basic auth. Also available: <code>/api/v1/admin/hostnames</code> and <code>/api/v1/admin/records</code>.</li>
</ol>
</div>
<div>
<h2>Create</h2>
{_code_window(create_example, copy_label="create command", class_name="mt-3")}
</div>
<div>
<h2>Register</h2>
{_code_window(register_example, copy_label="register command", class_name="mt-3")}
</div>
<div>
<h2>Update</h2>
{_code_window(update_example, copy_label="update command", class_name="mt-3")}
</div>
<div>
<h2>Status</h2>
<p class="section-copy">Use the private dashboard secret to read current DNS state and recent updates for only that hostname.</p>
{_code_window(status_example, copy_label="status command", class_name="mt-3")}
</div>
<div>
<h2>Rotate update URL</h2>
<p class="section-copy">Use the private dashboard secret to replace the router update URL. The old update URL stops working immediately.</p>
{_code_window(rotate_example, copy_label="rotate command", class_name="mt-3")}
</div>
<div>
<h2>Schema</h2>
<p class="section-copy">Machine-readable OpenAPI is still available. Operator API routes are included and require admin Basic auth.</p>
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
</div>
<div>
<h2>Operator API</h2>
<p class="section-copy">Admin endpoints use the same HTTP Basic auth as the operator console.</p>
{_code_window(admin_dashboard_example, copy_label="admin dashboard command", class_name="mt-3")}
{_code_window(admin_history_example, copy_label="admin history command", class_name="mt-3")}
</div>
</div>
</section>
</main>
"""),
description=description,
canonical_url=f"{base_url}/docs",
image_url=f"{base_url}/social-preview.png",
image_alt="KarlDNS API documentation",
)
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from ..security import dashboard_csrf_token
from ..services.ddns import DdnsService
from .components import (
_code_window,
_copy_menu,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_secret_notice,
)
from .html_utils import SafeHtml, attr, escape, trusted
def _linux_installer_section(base_url: str) -> SafeHtml:
base_url = base_url.rstrip("/")
system_install = f"curl -fsSL {base_url}/install.sh | sudo sh"
system_uninstall = f"curl -fsSL {base_url}/uninstall.sh | sudo sh"
return trusted(
f"""
<section class="section" id="linux-installer">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p>
<h2>Linux updater.</h2>
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates and registers a hostname, installs the updater, schedules cron, and prints your private dashboard link. Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.</p>
</div>
<div class="installer-commands">
<div class="installer-command">
<h3>Install</h3>
<p class="section-copy">Sets up the updater and cron job.</p>
{_code_window(system_install, copy_label="install command", class_name="mt-3")}
</div>
<div class="installer-command">
<h3>Uninstall</h3>
<p class="section-copy">Stops the updater and leaves private URLs on disk.</p>
{_code_window(system_uninstall, copy_label="uninstall command", class_name="mt-3")}
</div>
</div>
</div>
</section>
"""
)
def _created_hostname_panel(service: DdnsService, created_account: dict[str, str] | None, request_ip: str | None = None) -> SafeHtml:
if not created_account:
return trusted("")
hostname = created_account["hostname"]
update_url = service.fritz_update_url(created_account)
management_url = service.dashboard_url(created_account)
registration_url = service.registration_url(created_account)
csrf_token = dashboard_csrf_token(created_account["management_slug"], created_account)
management_slug = attr(created_account["management_slug"])
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
fritzbox_fields = _fritzbox_fields(update_url, hostname, management_url)
manual_commands = _manual_update_commands(update_url, request_ip)
return trusted(
f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Pending setup</p>
<h2>{escape(hostname)}</h2>
<p class="section-copy">Copy these values now. Then register the hostname so router updates can start. Unregistered setups are removed after 24 hours.</p>
{_one_time_secret_notice()}
{_secret_notice(include_registration=True)}
<div class="copy-actions my-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Register URL", registration_url), ("CNAME", cname_record)])}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Register URL:", registration_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Private dashboard:", management_url, "Secret")}
</div>
<form class="mt-4" method="post" action="/m/{management_slug}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
</form>
{_manual_update_panel(manual_commands)}
</div>
</section>
"""
)
+3 -3
View File
@@ -39,11 +39,11 @@ dev = [
]
[project.scripts]
karldns = "router_dyndns.cli:main"
karldns = "karldns.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["router_dyndns"]
artifacts = ["router_dyndns/assets/*"]
packages = ["karldns"]
artifacts = ["karldns/assets/**/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
dnspython==2.8.0
fastapi==0.136.1
pydantic==2.13.4
uvicorn[standard]==0.47.0
-2
View File
@@ -1,2 +0,0 @@
"""Self-hosted DynDNS service for FRITZ!Box and compatible routers."""
-1
View File
@@ -1 +0,0 @@
: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
-18
View File
@@ -1,18 +0,0 @@
from __future__ import annotations
from .app import make_app
from .ddns_dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_store import DdnsStore
__all__ = [
"DdnsSettings",
"DdnsStore",
"DnsConfigurationError",
"DnsProviderError",
"DnsReconciliationError",
"UpdateResult",
"delete_dns_records",
"make_app",
"publish_dns",
]
-17
View File
@@ -1,17 +0,0 @@
from __future__ import annotations
from .schemas import (
ErrorResponse,
HostnameCredentials,
MagicHostnameRequest,
ManagedRecord,
RouterUpdateResponse,
)
__all__ = [
"ErrorResponse",
"HostnameCredentials",
"MagicHostnameRequest",
"ManagedRecord",
"RouterUpdateResponse",
]
-29
View File
@@ -1,29 +0,0 @@
from __future__ import annotations
from .dns import (
DnsConfigurationError,
DnsProviderError,
DnsReconciliationError,
delete_cloudflare_records,
delete_dns_records,
delete_rfc2136_records,
dns_backend_configured,
publish_cloudflare,
publish_dns,
publish_rfc2136,
validate_dns_provider_settings,
)
__all__ = [
"DnsConfigurationError",
"DnsProviderError",
"DnsReconciliationError",
"delete_cloudflare_records",
"delete_dns_records",
"delete_rfc2136_records",
"dns_backend_configured",
"publish_cloudflare",
"publish_dns",
"publish_rfc2136",
"validate_dns_provider_settings",
]
-5
View File
@@ -1,5 +0,0 @@
from __future__ import annotations
from .store import DdnsStore
__all__ = ["DdnsStore"]
-108
View File
@@ -1,108 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_service import BadIpError
from ..dependencies import ServiceDep, StoreDep
from ..schemas import (
ErrorResponse,
HostnameCredentials,
MagicHostnameRequest,
ManagedRecord,
RouterUpdateResponse,
_credentials_response,
_managed_record,
)
from .path_params import ManagementSlugPath, UpdateSlugPath
router = APIRouter(prefix="/api/v1")
@router.post(
"/hostnames/magic",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Create a KarlDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_magic_hostname(payload: MagicHostnameRequest, service: ServiceDep) -> HostnameCredentials:
account = await run_in_threadpool(service.create_managed_account, payload.username)
return _credentials_response(service, account)
@router.get(
"/management/{management_slug}",
response_model=ManagedRecord,
summary="Read a hostname by private dashboard link",
description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
tags=["management"],
responses={404: {"model": ErrorResponse}},
)
async def read_managed_hostname(management_slug: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> ManagedRecord:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return _managed_record(service, account)
@router.delete(
"/management/{management_slug}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by private dashboard link",
description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
tags=["management"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_managed_hostname(management_slug: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "api")
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS delete failed. Hostname was not deleted.") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/updates/{update_slug}",
response_model=RouterUpdateResponse,
summary="Router-compatible update endpoint",
tags=["updates"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def update_hostname(
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RouterUpdateResponse:
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
outcome = await run_in_threadpool(
service.apply_update,
request,
hostname,
myip,
ipaddr,
myipv6,
ip6addr,
)
except BadIpError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS update failed") from exc
result = outcome.result
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
-97
View File
@@ -1,97 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip
from ..dependencies import ServiceDep, SettingsDep
from ..web.forms import first_form_value, request_form
from ..web.templates_public import _render_privacy_page, _render_public_home
router = APIRouter()
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
def index(settings: SettingsDep, service: ServiceDep) -> str:
return _render_public_home(service, settings, None, None)
@router.get("/magic", include_in_schema=False)
def magic_form() -> RedirectResponse:
return RedirectResponse("/#create", status_code=303)
@router.post("/magic", response_class=HTMLResponse, include_in_schema=False)
async def magic_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
await request_form(request, settings)
account = await run_in_threadpool(service.create_managed_account, None)
return _render_public_home(service, settings, account, None)
@router.post("/accounts", response_class=HTMLResponse, include_in_schema=False)
async def self_service_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
form = await request_form(request, settings)
mode = first_form_value(form, "mode")
if mode and mode != "managed":
raise HTTPException(status_code=400, detail="custom hostnames are not supported; create a KarlDNS hostname and use a CNAME")
if first_form_value(form, "hostname") or first_form_value(form, "claim_secret"):
raise HTTPException(status_code=400, detail="custom hostnames are not supported; create a KarlDNS hostname and use a CNAME")
account = await run_in_threadpool(service.create_managed_account, None)
return _render_public_home(service, settings, account, None)
@router.get("/healthz", response_class=PlainTextResponse)
def healthz() -> str:
return "ok"
@router.get("/ip", response_class=PlainTextResponse)
def visible_ip(request: Request, settings: SettingsDep) -> str:
return client_ip(request, settings)
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
def privacy(settings: SettingsDep) -> str:
return _render_privacy_page(settings)
@router.get("/robots.txt", response_class=PlainTextResponse, include_in_schema=False)
def robots_txt(settings: SettingsDep) -> str:
base_url = settings.public_base_url.rstrip("/")
admin_path = settings.admin_path.rstrip("/")
return "\n".join(
[
"User-agent: *",
f"Disallow: {admin_path}/",
"Disallow: /m/",
"Disallow: /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")
-53
View File
@@ -1,53 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from ..dependencies import ServiceDep, StoreDep, apply_update_response, authenticate_update
from .path_params import UpdateSlugPath
router = APIRouter()
@router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
def slug_update(
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
account = store.get_account_by_slug(update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])):
raise HTTPException(status_code=404, detail="not found")
return apply_update_response(service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr)
@router.get("/nic/update", response_class=PlainTextResponse, response_model=None)
def update(
request: Request,
store: StoreDep,
service: ServiceDep,
credentials: tuple[str | None, str | None] = Depends(authenticate_update),
hostname: str | None = Query(default=None),
domain: str | None = Query(default=None),
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
host = (hostname or domain or "").strip().lower().rstrip(".")
if not host:
return PlainTextResponse("nohost", status_code=400)
if not service.hostname_is_managed(host):
return PlainTextResponse("nohost", status_code=403)
supplied_user, supplied_token = credentials
account_ok = store.verify_account(host, supplied_user, supplied_token)
if not account_ok:
return PlainTextResponse("badauth", status_code=401)
return apply_update_response(service, request, host, myip, ipaddr, myipv6, ip6addr)
-62
View File
@@ -1,62 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field
from .ddns_service import DdnsService
class ErrorResponse(BaseModel):
detail: str = Field(examples=["not found"])
class MagicHostnameRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class HostnameCredentials(BaseModel):
hostname: str
username: str
password: str = Field(description="Secret router password. Store it now; it is not recoverable later.")
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
credentialed_update_url: str = Field(
description="Secret full update URL with router username and password included."
)
management_url: str = Field(description="Secret dashboard URL for reading or deleting this hostname.")
class ManagedRecord(BaseModel):
hostname: str
username: str
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
class RouterUpdateResponse(BaseModel):
status: str = Field(examples=["good"])
hostname: str
ip: str
def _credentials_response(service: DdnsService, account: dict[str, str]) -> HostnameCredentials:
return HostnameCredentials(
hostname=account["hostname"],
username=account["username"],
password=account["password"],
update_url=service.fritz_update_url(account),
credentialed_update_url=service.credentialed_update_url(account),
management_url=service.magic_management_url(account),
)
def _managed_record(service: DdnsService, account: dict[str, str | int | None]) -> ManagedRecord:
account_for_url = {"update_slug": str(account["update_slug"])}
return ManagedRecord(
hostname=str(account["hostname"]),
username=str(account["username"]),
update_url=service.fritz_update_url(account_for_url),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
)
-360
View File
@@ -1,360 +0,0 @@
from __future__ import annotations
import json
from .html_utils import SafeHtml, attr, escape, join_html, trusted
def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
if name == "check":
path = '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75 10.5 18.75 19.5 5.25"/>'
elif name == "more":
path = (
'<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75h.01M12 12h.01M12 17.25h.01"/>'
)
else:
path = (
'<path stroke-linecap="round" stroke-linejoin="round" '
'd="M16.5 8.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v8.25A2.25 2.25 0 0 0 6 16.5h2.25'
'm8.25-8.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-7.5A2.25 2.25 0 0 1 8.25 18'
'v-7.5A2.25 2.25 0 0 1 10.5 8.25h6Z"/>'
)
return trusted(
f'<svg class="{attr(css_class)}" aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" '
f'fill="none" stroke="currentColor" stroke-width="1.5">{path}</svg>'
)
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
if label:
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">Docs</a><a class="nav-link px-0" href="/redoc">API</a>')
theme_toggle = """
<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
<svg class="theme-icon theme-icon-moon" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 0a6.8 6.8 0 0 0-.8 3.2A7 7 0 0 0 12.2 10a6.8 6.8 0 0 0 3-.7A8 8 0 1 1 6 0Z"/>
</svg>
<svg class="theme-icon theme-icon-sun" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 11.2A3.2 3.2 0 1 0 8 4.8a3.2 3.2 0 0 0 0 6.4Zm0-9.8a.7.7 0 0 1-.7-.7V.2a.7.7 0 0 1 1.4 0v.5a.7.7 0 0 1-.7.7Zm0 14.6a.7.7 0 0 1-.7-.7v-.5a.7.7 0 1 1 1.4 0v.5a.7.7 0 0 1-.7.7ZM1.4 8a.7.7 0 0 1-.7.7H.2a.7.7 0 0 1 0-1.4h.5a.7.7 0 0 1 .7.7Zm14.6 0a.7.7 0 0 1-.7.7h-.5a.7.7 0 1 1 0-1.4h.5a.7.7 0 0 1 .7.7ZM3.3 3.3a.7.7 0 0 1-1 0L2 3a.7.7 0 0 1 1-1l.3.3a.7.7 0 0 1 0 1Zm10.7 10.7a.7.7 0 0 1-1 0l-.3-.3a.7.7 0 1 1 1-1l.3.3a.7.7 0 0 1 0 1Zm-10.7-1a.7.7 0 0 1 0 1l-.3.3a.7.7 0 1 1-1-1l.3-.3a.7.7 0 0 1 1 0ZM14 2a.7.7 0 0 1 0 1l-.3.3a.7.7 0 1 1-1-1L13 2a.7.7 0 0 1 1 0Z"/>
</svg>
</button>
"""
return trusted(
f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
{links}
{account_html}
{theme_toggle}
</div>
</div>
</nav>
"""
)
def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtml:
safe_label = escape(label)
safe_value = escape(value)
badge = ""
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
if sensitivity:
safe_sensitivity = escape(sensitivity)
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
value_field = (
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
if is_secret
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
)
reveal_button = (
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>'
if is_secret
else ""
)
return trusted(
f"""
<div class="input-group copy-field">
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
{value_field}
{reveal_button}
<button type="button" class="btn btn-outline-secondary copy-button" data-copy="{safe_value}" aria-label="Copy {safe_label}">
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button>
</div>
"""
)
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
safe_style = attr(style)
return trusted(
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_icon_button(label: str, value: str) -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
return trusted(
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
safe_label = escape(label)
buttons = []
for item_label, item_value in items:
buttons.append(
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
)
return trusted(
f"""
<details class="copy-menu">
<summary aria-label="{safe_label}" title="{safe_label}">
{_svg_icon("more", "more-icon")}
</summary>
<div class="copy-menu-popover" role="menu">
{''.join(buttons)}
</div>
</details>
"""
)
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "") -> SafeHtml:
safe_class = f" {attr(class_name)}" if class_name else ""
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
return trusted(
f"""
<div class="terminal-card code-window{safe_class}">
{_copy_icon_button(copy_label, commands)}
{code_lines}
</div>
"""
)
def _manual_update_commands(update_url: str) -> str:
update_base = update_url.split("?", 1)[0]
return "\n".join(
[
f"curl -sS '{update_base}?myip=203.0.113.10'",
f"curl -sS '{update_base}?myipv6=2001:db8::10'",
f"curl -sS '{update_base}' # use request IP",
]
)
def _manual_update_panel(commands: str) -> SafeHtml:
return trusted(
f"""
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<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>
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
"""
)
def _fritzbox_fields(
update_url: str,
hostname: str,
username: str,
password: str,
management_url: str | None = None,
) -> str:
lines = [
f"Update-URL: {update_url}",
f"Domainnamen: {hostname}",
f"CNAME target: {hostname}",
f"Benutzername: {username}",
f"Kennwort: {password}",
]
if management_url:
lines.append(f"Private dashboard: {management_url}")
return "\n".join(lines)
def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Hostname and CNAME target can be shared.
<strong>Secret:</strong> Update URLs, router password, and dashboard link can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
)
def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the router password and full update URL now. They are only shown when created or rotated.
</div>
"""
)
def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
def _site_footer() -> SafeHtml:
return trusted(
"""
<footer class="site-footer border-top">
<div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
<nav class="footer-links" aria-label="Footer links">
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A12 12 0 0 0 8.2 23.9c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.4-4-1.4-.5-1.3-1.2-1.7-1.2-1.7-1-.7.1-.7.1-.7 1.1.1 1.7 1.2 1.7 1.2 1 .1.6 2.7 3.4 1.9.1-.7.4-1.2.7-1.5-2.6-.3-5.4-1.3-5.4-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.4 5.9.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6A12 12 0 0 0 12 .5Z"/></svg>
<span>GitHub</span>
</a>
<a class="footer-link" href="https://karl.fail" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6.5h16M4 12h10M4 17.5h7"/><path stroke-linecap="round" d="M18 15.5h.01M18 18.5h.01"/></svg>
<span>karl.fail</span>
</a>
<a class="footer-link" href="https://karlcom.de" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="8.5"/><path stroke-linecap="round" d="M3.5 12h17M12 3.5c2.2 2.3 3.3 5.1 3.3 8.5S14.2 18.2 12 20.5M12 3.5C9.8 5.8 8.7 8.6 8.7 12S9.8 18.2 12 20.5"/></svg>
<span>karlcom.de</span>
</a>
<a class="footer-link" href="/docs">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M6 4.5h8.5L18 8v11.5H6z"/><path stroke-linecap="round" stroke-linejoin="round" d="M14.5 4.5V8H18M8.5 12h7M8.5 15h7"/></svg>
<span>API docs</span>
</a>
<a class="footer-link" href="/redoc">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="m9 8-4 4 4 4M15 8l4 4-4 4"/></svg>
<span>ReDoc</span>
</a>
<a class="footer-link" href="/privacy">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3.5 5.5 6v5.5c0 4.1 2.7 7.6 6.5 9 3.8-1.4 6.5-4.9 6.5-9V6z"/><path stroke-linecap="round" stroke-linejoin="round" d="m9 12 2 2 4-4"/></svg>
<span>Legal & privacy</span>
</a>
</nav>
</div>
</footer>
"""
)
def _page(
title: str,
body: SafeHtml,
extra_styles: SafeHtml | None = None,
extra_scripts: SafeHtml | None = None,
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
preload_logo: bool = False,
canonical_url: str | None = None,
image_url: str | None = None,
image_alt: str | None = None,
structured_data: dict[str, object] | None = None,
) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("")
extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or ""
social_image = image_url or ""
social_html = trusted("")
if social_url:
social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title
social_image_html = f"""
<meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
""" if social_image else ""
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
<meta property="og:description" content="{attr(description)}">
<meta property="og:url" content="{attr(social_url)}">
{social_image_html}
<meta name="twitter:card" content="{attr(social_card)}">
<meta name="twitter:title" content="{attr(title)}">
<meta name="twitter:description" content="{attr(description)}">
""")
structured_data_html = trusted("")
if structured_data:
structured_data_html = trusted(
'<script type="application/ld+json">'
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
+ "</script>"
)
return f"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<meta name="description" content="{attr(description)}">
{canonical_html}
{social_html}
<title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
{preload_logo_html}
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=3" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=5" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
</head>
<body>{body}{_site_footer()}</body>
</html>
"""
__all__ = [
"SafeHtml",
"_copy_button",
"_copy_icon_button",
"_copy_menu",
"_copy_row",
"_code_window",
"_fritzbox_fields",
"_manual_update_commands",
"_manual_update_panel",
"_message_band",
"_one_time_secret_notice",
"_page",
"_secret_notice",
"_top_nav",
"join_html",
"trusted",
]
-172
View File
@@ -1,172 +0,0 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import escape, trusted
from .templates import _code_window, _message_band, _page, _top_nav
from .templates_public_sections import _credentials_panel, _linux_installer_section
def _render_public_home(
service: DdnsService,
settings: DdnsSettings,
created_account: dict[str, str] | None,
message: str | None,
) -> str:
base_url = settings.public_base_url.rstrip("/")
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
suffix = escape(hostname_suffix)
demo_update_url = escape(f"{base_url}/u/<secret>?myip=<ipaddr>")
cname_example = f"home.example.com. CNAME your-name.{hostname_suffix}."
create_demo = "$ curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'\ngood 203.0.113.10"
manual_demo = "\n".join(
[
"# explicit IPv4",
"curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'",
"# source IP fallback",
"curl -sS 'https://karldns.de/u/<secret>'",
]
)
created_html = _credentials_panel(service, created_account)
message_html = _message_band(message) if message else ""
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
return _page(
"KarlDNS",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band">
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
<h1>Dynamic DNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<a class="btn btn-primary rounded-pill" href="#create">Create hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
</div>
</div>
</section>
{message_html}
{created_html}
<section class="section" id="create">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Hostname</p>
<h2>No account needed.</h2>
<p class="section-copy mx-auto">Create a KarlDNS hostname. Use it directly, or point your own DNS name at it with a CNAME.</p>
</div>
<div class="row g-4 align-items-stretch">
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">KarlDNS hostname</p>
<h3>Create a hostname</h3>
<p class="section-copy mb-0">You get a hostname, a private update URL, and router fields if your router needs them.</p>
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
</div>
</div>
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Optional DNS alias</p>
<h3>Add a CNAME</h3>
<p class="section-copy mb-0">After creation, add a CNAME at your DNS provider:</p>
{_code_window(cname_example, copy_label="CNAME example")}
<p class="small text-secondary mb-0">KarlDNS updates only the generated hostname. The external DNS name follows it through CNAME.</p>
</div>
</div>
</div>
{_code_window(create_demo, copy_label="update example", class_name="mt-4")}
</div>
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from a router or script.</h2>
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
{_code_window(manual_demo, copy_label="manual update examples", class_name="text-start mt-4")}
</div>
</section>
{_linux_installer_section()}
</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",
"CNAME target for external DNS names",
"Linux cron updater",
],
},
)
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, IP history, and cleanup behavior."
return _page(
"Legal and Privacy",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Legal and privacy</p>
<h1>Privacy basics.</h1>
<p class="lead">KarlDNS stores only the data needed to keep your hostname updated.</p>
</div>
</section>
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, recent IP history, timestamps, and service events.</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Dashboard links, update URLs, router passwords, and full update URLs are secrets. Anyone with them may update or manage the hostname. Passwords and dashboard lookup values are not stored as readable text.</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.</p>
</div>
<div>
<h2>Contact</h2>
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
</div>
</div>
</section>
</main>
"""),
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}/",
},
},
)
@@ -1,95 +0,0 @@
from __future__ import annotations
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, escape, trusted
from .templates import (
_code_window,
_copy_menu,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_secret_notice,
)
def _linux_installer_section() -> SafeHtml:
system_install = "curl -fsSL https://karldns.de/install.sh | sudo sh"
system_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sudo sh"
user_install = "curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user"
clean_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sh -s -- --mode user --delete-config"
return trusted(
f"""
<section class="section" id="linux-installer">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p>
<h2>Linux updater.</h2>
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates a hostname, installs the updater, schedules it, and prints your private dashboard link.</p>
</div>
<div class="card card-body gap-3">
<div>
<h3>Install</h3>
<p class="section-copy">System install for most Linux servers.</p>
</div>
{_code_window(system_install, copy_label="install command")}
<div>
<h3>Uninstall</h3>
<p class="section-copy">Stops the updater and leaves your private URLs on disk.</p>
</div>
{_code_window(system_uninstall, copy_label="uninstall command")}
<div class="alert alert-info my-0 py-2 small" role="note">
Uninstall keeps your private URLs by default. Use <code>--delete-config</code> only when you want to remove everything.
</div>
<div>
<h3>User install</h3>
<p class="section-copy">Use this when you do not want a system-wide install.</p>
</div>
{_code_window(user_install, copy_label="user install command")}
{_copy_menu("More copy options", [("Clean user uninstall", clean_uninstall)])}
</div>
</div>
</section>
"""
)
def _credentials_panel(service: DdnsService, created_account: dict[str, str] | None) -> SafeHtml:
if not created_account:
return trusted("")
hostname = created_account["hostname"]
username = created_account["username"]
password = created_account["password"]
update_url = service.fritz_update_url(created_account)
credentialed_update_url = service.credentialed_update_url(created_account)
management_url = service.magic_management_url(created_account)
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, password, management_url)
manual_commands = _manual_update_commands(update_url)
return trusted(
f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Hostname ready</p>
<h2>{escape(hostname)}</h2>
<p class="section-copy">Use the Update-URL directly. Username and password are only for routers that require separate fields.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="copy-actions my-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Full update URL", credentialed_update_url), ("CNAME", cname_record)])}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Full update URL:", credentialed_update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", password, "Secret")}
{_copy_row("Private dashboard:", management_url, "Secret")}
</div>
{_manual_update_panel(manual_commands)}
</div>
</section>
"""
)
+13 -6
View File
@@ -6,15 +6,18 @@ import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = ROOT / "router_dyndns" / "assets" / "bootstrap-subset.css"
DEFAULT_OUTPUT = ROOT / "karldns" / "assets" / "bootstrap-subset.css"
SCAN_PATHS = [
ROOT / "router_dyndns" / "web",
ROOT / "router_dyndns" / "routers",
ROOT / "karldns" / "web",
ROOT / "karldns" / "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()
""".strip().replace(
".col-lg-3{flex:0 0 auto;width:25%}",
".col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.333333%}",
)
SUPPORTED_DYNAMIC_CLASSES = {"active", "disabled", "page-item", "page-link"}
@@ -44,14 +47,18 @@ def parse_args() -> argparse.Namespace:
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()
app_styles = (ROOT / "karldns" / "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)")
try:
display_path = output.relative_to(ROOT)
except ValueError:
display_path = output
print(f"Wrote {display_path} ({output.stat().st_size} bytes)")
return 0
+71 -9
View File
@@ -7,11 +7,13 @@ KARLDNS_INSTALL_MODE="${KARLDNS_INSTALL_MODE:-auto}"
KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL:-}"
KARLDNS_HOSTNAME="${KARLDNS_HOSTNAME:-}"
KARLDNS_MANAGEMENT_URL="${KARLDNS_MANAGEMENT_URL:-}"
KARLDNS_ACTIVATION_URL="${KARLDNS_ACTIVATION_URL:-}"
KARLDNS_RAW_BASE_URL="${KARLDNS_RAW_BASE_URL:-https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts}"
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
KARLDNS_SKIP_CRON="${KARLDNS_SKIP_CRON:-0}"
KARLDNS_SKIP_FIRST_UPDATE="${KARLDNS_SKIP_FIRST_UPDATE:-0}"
KARLDNS_REUSED_CONFIG=0
usage() {
cat <<'EOF'
@@ -28,6 +30,7 @@ Options:
--update-url URL Existing private update URL
--hostname NAME Hostname to show after install
--dashboard-url URL Private dashboard URL to show after install
--activation-url URL Private registration URL for pending hostnames
--ipv4 VALUE auto, off, or a static IPv4. Default: auto
--ipv6 VALUE auto, off, or a static IPv6. Default: auto
--no-cron Install files only, do not configure cron
@@ -49,6 +52,7 @@ while [ "$#" -gt 0 ]; do
--update-url) KARLDNS_UPDATE_URL="${2:?missing value for --update-url}"; shift 2 ;;
--hostname) KARLDNS_HOSTNAME="${2:?missing value for --hostname}"; shift 2 ;;
--dashboard-url|--management-url) KARLDNS_MANAGEMENT_URL="${2:?missing value for --dashboard-url}"; shift 2 ;;
--activation-url) KARLDNS_ACTIVATION_URL="${2:?missing value for --activation-url}"; shift 2 ;;
--ipv4) KARLDNS_IPV4="${2:?missing value for --ipv4}"; shift 2 ;;
--ipv6) KARLDNS_IPV6="${2:?missing value for --ipv6}"; shift 2 ;;
--no-cron) KARLDNS_SKIP_CRON=1; shift ;;
@@ -81,6 +85,7 @@ quote_sh() {
download_updater() {
target_path="$1"
tmp_path="$(mktemp)"
mkdir -p "$(dirname "$target_path")"
if ! "$curl_bin" -fsS "$KARLDNS_RAW_BASE_URL/karldns-update.sh" -o "$tmp_path"; then
rm -f "$tmp_path"
exit 1
@@ -89,6 +94,18 @@ download_updater() {
mv "$tmp_path" "$target_path"
}
load_existing_config() {
if [ -n "$KARLDNS_UPDATE_URL" ]; then
return
fi
config_file="$1"
if [ -r "$config_file" ]; then
# shellcheck disable=SC1090
. "$config_file"
KARLDNS_REUSED_CONFIG=1
fi
}
cron_schedule() {
if [ "$KARLDNS_INTERVAL_MINUTES" = "60" ]; then
printf '0 * * * *'
@@ -113,6 +130,44 @@ update_crontab() {
printf '%s\n' "$marker_end"
}
install_system_cron() {
schedule="$1"
command="$2"
if [ -d /etc/cron.d ]; then
cron_file="/etc/cron.d/karldns-updater"
cat > "$cron_file" <<EOF
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$schedule root $command
EOF
chmod 644 "$cron_file"
return
fi
if [ -d /etc/crontabs ]; then
cron_file="/etc/crontabs/root"
tmp_file="$(mktemp)"
update_crontab "$cron_file" "$schedule" "$command" > "$tmp_file"
cat "$tmp_file" > "$cron_file"
rm -f "$tmp_file"
chmod 600 "$cron_file" 2>/dev/null || true
return
fi
if command -v crontab >/dev/null 2>&1; then
cron_file="$(mktemp)"
crontab -l > "$cron_file" 2>/dev/null || true
update_crontab "$cron_file" "$schedule" "$command" > "$cron_file.new"
mv "$cron_file.new" "$cron_file"
crontab "$cron_file"
rm -f "$cron_file"
return
fi
echo "KarlDNS installer: cron is not available. Install cron or re-run with --no-cron." >&2
exit 1
}
write_config() {
config_file="$1"
cache_file="$2"
@@ -123,6 +178,7 @@ write_config() {
printf 'KARLDNS_UPDATE_URL=%s\n' "$(quote_sh "$KARLDNS_UPDATE_URL")"
printf 'KARLDNS_HOSTNAME=%s\n' "$(quote_sh "$KARLDNS_HOSTNAME")"
printf 'KARLDNS_MANAGEMENT_URL=%s\n' "$(quote_sh "$KARLDNS_MANAGEMENT_URL")"
printf 'KARLDNS_ACTIVATION_URL=%s\n' "$(quote_sh "$KARLDNS_ACTIVATION_URL")"
printf 'KARLDNS_CACHE_FILE=%s\n' "$(quote_sh "$cache_file")"
printf 'KARLDNS_LOCK_FILE=%s\n' "$(quote_sh "$lock_file")"
printf 'KARLDNS_IPV4=%s\n' "$(quote_sh "$KARLDNS_IPV4")"
@@ -161,7 +217,6 @@ install_system() {
cache_dir="/var/cache/karldns"
config_file="${config_dir}/updater.env"
updater_path="/usr/local/bin/karldns-update"
cron_file="/etc/cron.d/karldns-updater"
mkdir -p "$config_dir" "$cache_dir"
download_updater "$updater_path"
@@ -169,12 +224,7 @@ install_system() {
write_config "$config_file" "$cache_dir/last-ip" "/run/karldns-updater.lock"
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
cat > "$cron_file" <<EOF
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$(cron_schedule) root KARLDNS_CONFIG=$(quote_sh "$config_file") $(quote_sh "$updater_path") >/dev/null 2>&1
EOF
chmod 644 "$cron_file"
install_system_cron "$(cron_schedule)" "KARLDNS_CONFIG=$(quote_sh "$config_file") $(quote_sh "$updater_path") >/dev/null 2>&1"
fi
}
@@ -206,12 +256,19 @@ if [ "$KARLDNS_INSTALL_MODE" = "system" ] && [ "$(id -u)" -ne 0 ]; then
exit 1
fi
if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
load_existing_config /etc/karldns/updater.env
else
load_existing_config "${XDG_CONFIG_HOME:-$HOME/.config}/karldns/updater.env"
fi
if [ -z "$KARLDNS_UPDATE_URL" ]; then
echo "KarlDNS: creating a new hostname at $KARLDNS_BASE_URL ..."
response="$("$curl_bin" -fsS --max-time 30 -H 'content-type: application/json' -d '{}' "$KARLDNS_BASE_URL/api/v1/hostnames/magic")"
response="$("$curl_bin" -fsS --max-time 30 -X POST "$KARLDNS_BASE_URL/api/v1/hostnames")"
KARLDNS_UPDATE_URL="$(printf '%s' "$response" | json_field update_url)"
KARLDNS_HOSTNAME="$(printf '%s' "$response" | json_field hostname)"
KARLDNS_MANAGEMENT_URL="$(printf '%s' "$response" | json_field management_url)"
KARLDNS_MANAGEMENT_URL="$(printf '%s' "$response" | json_field dashboard_url)"
KARLDNS_ACTIVATION_URL="$(printf '%s' "$response" | json_field registration_url)"
fi
if [ -z "$KARLDNS_UPDATE_URL" ]; then
@@ -222,6 +279,11 @@ case "$KARLDNS_UPDATE_URL" in
*"<ipaddr>"*|*"<ip6addr>"*) KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL%%\?*}" ;;
esac
if [ -n "$KARLDNS_ACTIVATION_URL" ] && [ "$KARLDNS_REUSED_CONFIG" = "0" ]; then
echo "KarlDNS: registering hostname ..."
"$curl_bin" -fsS --max-time 30 -X POST "$KARLDNS_ACTIVATION_URL" >/dev/null
fi
if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
install_system
else
+2 -3
View File
@@ -13,7 +13,6 @@ KARLDNS_BASE_URL="${KARLDNS_BASE_URL:-https://karldns.de}"
KARLDNS_LOCK_FILE="${KARLDNS_LOCK_FILE:-/tmp/karldns-updater.lock}"
KARLDNS_CACHE_FILE="${KARLDNS_CACHE_FILE:-${XDG_CACHE_HOME:-$HOME/.cache}/karldns/last-ip}"
KARLDNS_CURL="${KARLDNS_CURL:-curl}"
KARLDNS_IP_URL="${KARLDNS_IP_URL:-$KARLDNS_BASE_URL/ip}"
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
case "$KARLDNS_UPDATE_URL" in
@@ -39,12 +38,12 @@ fi
ipv4=""
ipv6=""
if [ "$KARLDNS_IPV4" = "auto" ]; then
ipv4="$("$KARLDNS_CURL" -4 -fsS --max-time 15 "$KARLDNS_IP_URL" 2>/dev/null | tr -d '[:space:]' || true)"
ipv4=""
elif [ "$KARLDNS_IPV4" != "off" ]; then
ipv4="$KARLDNS_IPV4"
fi
if [ "$KARLDNS_IPV6" = "auto" ]; then
ipv6="$("$KARLDNS_CURL" -6 -fsS --max-time 15 "$KARLDNS_IP_URL" 2>/dev/null | tr -d '[:space:]' || true)"
ipv6=""
elif [ "$KARLDNS_IPV6" != "off" ]; then
ipv6="$KARLDNS_IPV6"
fi
+29
View File
@@ -48,6 +48,33 @@ remove_user() {
fi
}
remove_marked_crontab_file() {
cron_file="$1"
[ -f "$cron_file" ] || return 0
tmp_file="$(mktemp)"
awk '
$0 == "# karldns-update start" {skip = 1; next}
$0 == "# karldns-update end" {skip = 0; next}
skip != 1 {print}
' "$cron_file" > "$tmp_file"
cat "$tmp_file" > "$cron_file"
rm -f "$tmp_file"
}
remove_marked_root_crontab() {
if ! command -v crontab >/dev/null 2>&1; then
return 0
fi
tmp_file="$(mktemp)"
crontab -l 2>/dev/null | awk '
$0 == "# karldns-update start" {skip = 1; next}
$0 == "# karldns-update end" {skip = 0; next}
skip != 1 {print}
' > "$tmp_file" || true
crontab "$tmp_file"
rm -f "$tmp_file"
}
remove_system() {
if [ "$(id -u)" -ne 0 ]; then
echo "System uninstall needs root. Re-run with sudo or set --mode user." >&2
@@ -55,6 +82,8 @@ remove_system() {
fi
rm -f /etc/cron.d/karldns-updater /usr/local/bin/karldns-update
remove_marked_crontab_file /etc/crontabs/root
remove_marked_root_crontab
if [ "$KARLDNS_DELETE_CONFIG" = "1" ]; then
rm -rf /etc/karldns /var/cache/karldns
fi
+53 -27
View File
@@ -2,8 +2,8 @@ from pathlib import Path
from fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
from router_dyndns.ddns_security import admin_csrf_token
from karldns import DdnsSettings, DdnsStore, make_app
from karldns.security import admin_csrf_token
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
@@ -48,12 +48,12 @@ def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
response = client.get("/ops", auth=("admin", "admin"))
assert response.status_code == 200
assert 'href="/ops/records"' in response.text
assert 'href="/ops/events"' in response.text
assert 'href="/ops/records"' not in response.text
assert 'href="/ops/events"' not in response.text
assert 'action="/ops/cleanup"' in response.text
assert 'action="/ops/accounts/rotate"' in response.text
assert 'action="/ops/hostnames/rotate"' in response.text
assert 'action="/admin/cleanup"' not in response.text
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 200
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 404
assert client.get("/records", auth=("admin", "admin")).status_code == 404
assert client.get("/admin", auth=("admin", "admin")).status_code == 404
@@ -70,8 +70,8 @@ def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
records = client.get("/records?limit=1&offset=1", auth=("admin", "admin")).json()
events = client.get("/events?limit=1&offset=1", auth=("admin", "admin")).json()
records = client.get("/api/v1/admin/records?limit=1&offset=1", auth=("admin", "admin")).json()
events = client.get("/api/v1/admin/events?limit=1&offset=1", auth=("admin", "admin")).json()
assert records["limit"] == 1
assert records["offset"] == 1
@@ -84,12 +84,48 @@ def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
assert len(events["items"]) == 1
def test_admin_api_exposes_dashboard_data_and_history(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "router")
store.upsert(account["hostname"], "203.0.113.10", None)
store.log_update_event(account["hostname"], "203.0.113.10", None, "updated", "ok", "198.51.100.1")
store.increment_metric("update_requests_total")
settings = DdnsSettings(database_path=database_path, admin_password="admin")
app = make_app(settings)
client = TestClient(app)
assert client.get("/api/v1/admin/dashboard").status_code == 401
dashboard = client.get("/api/v1/admin/dashboard?limit=10", auth=("admin", "admin"))
hostnames = client.get("/api/v1/admin/hostnames?limit=1&offset=0", auth=("admin", "admin"))
records = client.get("/api/v1/admin/records?limit=1&offset=0", auth=("admin", "admin"))
events = client.get("/api/v1/admin/events?limit=1&offset=0", auth=("admin", "admin"))
assert dashboard.status_code == 200
assert dashboard.headers["cache-control"] == "no-store"
dashboard_json = dashboard.json()
assert dashboard_json["totals"]["hostnames"] == 1
assert dashboard_json["totals"]["records"] == 1
assert dashboard_json["totals"]["update_events"] == 1
assert dashboard_json["metrics"]["values"]["update_requests_total"] == 1
assert dashboard_json["hostnames"][0]["hostname"] == "home.example.net"
assert dashboard_json["hostnames"][0]["status"] == "active"
assert dashboard_json["hostnames"][0]["ipv4"] == "203.0.113.10"
assert dashboard_json["update_events"][0]["status"] == "updated"
assert dashboard_json["scheduled_tasks"][0]["name"] == "cleanup"
assert hostnames.json()["items"][0]["hostname"] == "home.example.net"
assert records.json()["items"][0]["ipv4"] == "203.0.113.10"
assert events.json()["items"][0]["hostname"] == "home.example.net"
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"))
records = client.get("/api/v1/admin/records?offset=10001", auth=("admin", "admin"))
events = client.get("/api/v1/admin/events?offset=10001", auth=("admin", "admin"))
assert records.status_code == 422
assert events.status_code == 422
@@ -125,8 +161,8 @@ def test_update_events_support_keyset_pagination(tmp_path: Path) -> None:
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()
first = client.get("/api/v1/admin/events?limit=1", auth=("admin", "admin")).json()
second = client.get(f"/api/v1/admin/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"
@@ -184,16 +220,16 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
csrf = admin_csrf_token(settings)
update = client.post(
"/admin/accounts/rotate",
"/admin/hostnames/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
)
assert update.status_code == 200
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"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
management = client.post(
"/admin/accounts/rotate",
"/admin/hostnames/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
)
@@ -201,20 +237,10 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
assert "Dashboard link changed" in management.text
assert "/m/" in management.text
password = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
)
assert password.status_code == 200
assert "Router password changed" in password.text
assert "Full update URL:" in password.text
assert "/nic/update?" in password.text
assert "Kennwort (optional):" in password.text
statuses = {event["status"] for event in store.list_update_events_for_hostname("home.example.net", 10)}
assert "admin_update_url_rotated" in statuses
assert "admin_management_url_rotated" in statuses
assert "admin_router_credentials_rotated" in statuses
assert "admin_router_credentials_rotated" not in statuses
def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -> None:
@@ -225,7 +251,7 @@ def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -
client = TestClient(app)
response = client.post(
"/admin/accounts/delete",
"/admin/hostnames/delete",
auth=("admin", "admin"),
data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "},
follow_redirects=False,
+225 -124
View File
@@ -1,81 +1,95 @@
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from urllib.parse import urlparse
import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
import karldns.services.ddns as ddns_module
from karldns import DdnsSettings, DdnsStore, DnsProviderError, make_app
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
def _path(url: str) -> str:
parsed = urlparse(url)
return parsed.path
def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=database_path,
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert response.status_code == 201
payload = response.json()
one_box_url = payload["credentialed_update_url"]
parsed = urlparse(one_box_url)
params = parse_qs(parsed.query)
assert parsed.path == "/nic/update"
assert params["hostname"] == [payload["hostname"]]
assert params["username"] == [payload["username"]]
assert params["password"] == [payload["password"]]
assert params["myip"] == ["<ipaddr>"]
assert params["myipv6"] == ["<ip6addr>"]
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
docs = client.get("/docs")
redoc = client.get("/redoc")
schema = client.get("/openapi.json").json()
assert docs.status_code == 200
assert redoc.status_code == 200
assert docs.headers["x-robots-tag"] == "noindex, nofollow"
assert redoc.headers["x-robots-tag"] == "noindex, nofollow"
assert redoc.status_code == 404
assert "Small API surface." in docs.text
assert "POST /api/v1/hostnames" in docs.text
assert "GET or POST /api/v1/router/register/&lt;secret&gt;" in docs.text
assert "GET or POST /api/v1/router/update/&lt;secret&gt;" in docs.text
assert "GET /api/v1/hostnames/&lt;secret&gt;" in docs.text
assert "POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url" in docs.text
assert "Rotate update URL" in docs.text
assert "GET /api/v1/admin/dashboard" in docs.text
assert "GET /api/v1/admin/events" in docs.text
assert "https://karldns.de/api/v1/hostnames" in docs.text
assert "http://localhost:8080/api/v1/hostnames" not in docs.text
assert "registration_url" in docs.text
assert "/api/v1/management" not in docs.text
assert "/api/v1/hostnames/magic" not in docs.text
assert "/nic/update" not in docs.text
assert "/u/&lt;secret&gt;" not in docs.text
assert "swagger-ui" not in docs.text.lower()
assert "redoc" not in docs.text.lower()
assert "x-robots-tag" not in docs.headers
assert client.get("/openapi.json").headers["x-robots-tag"] == "noindex, nofollow"
assert "unsafe-inline" in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
assert "/api/v1/hostnames/magic" in schema["paths"]
assert "/api/v1/updates/{update_slug}" in schema["paths"]
assert "/u/{update_slug}" in schema["paths"]
assert "/nic/update" in schema["paths"]
assert "/ip" in schema["paths"]
assert "/healthz" in schema["paths"]
assert "cdn.jsdelivr.net" not in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" not in docs.headers["content-security-policy"]
expected_paths = {
"/api/v1/admin/dashboard",
"/api/v1/admin/events",
"/api/v1/admin/hostnames",
"/api/v1/admin/records",
"/api/v1/hostnames",
"/api/v1/hostnames/{dashboard_secret}",
"/api/v1/hostnames/{dashboard_secret}/rotate-update-url",
"/api/v1/router/register/{dashboard_secret}",
"/api/v1/router/update/{update_slug}",
}
assert set(schema["paths"]) == expected_paths
assert "/" not in schema["paths"]
assert "/admin" not in schema["paths"]
assert "/healthz" not in schema["paths"]
assert "/records" not in schema["paths"]
assert "/events" not in schema["paths"]
assert "/" not in schema["paths"]
assert "/magic" not in schema["paths"]
assert "/accounts" not in schema["paths"]
assert "/m/{management_slug}" not in schema["paths"]
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
assert all(tag["name"] != "admin" for tag in schema["tags"])
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
assert "Dashboard passwords protect only the web dashboard" in management_tag["description"]
management_get = schema["paths"]["/api/v1/management/{management_slug}"]["get"]
management_delete = schema["paths"]["/api/v1/management/{management_slug}"]["delete"]
assert "Dashboard passwords protect only the web dashboard" in management_get["description"]
assert "Dashboard passwords protect only the web dashboard" in management_delete["description"]
assert any(tag["name"] == "router" for tag in schema["tags"])
assert any(tag["name"] == "admin" for tag in schema["tags"])
def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
def test_custom_api_docs_use_visitor_public_ip(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="https://karldns.de",
)
)
client = TestClient(app, client=("8.8.4.4", 12345))
response = client.get("/docs")
assert response.status_code == 200
assert "myip=8.8.4.4" in response.text
def test_openapi_hides_custom_operator_routes(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
@@ -85,78 +99,38 @@ def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
)
client = TestClient(app)
redoc = client.get("/redoc")
schema = client.get("/openapi.json").json()
forbidden_paths = {
"/ops",
"/ops/accounts/delete",
"/ops/accounts/rotate",
"/ops/hostnames/delete",
"/ops/hostnames/rotate",
"/ops/cleanup",
"/ops/events",
"/ops/records",
"/records",
"/events",
}
assert redoc.status_code == 200
assert client.get("/redoc").status_code == 404
assert forbidden_paths.isdisjoint(schema["paths"])
assert all("admin" not in tag["name"].lower() for tag in schema["tags"])
assert "/api/v1/admin/dashboard" in schema["paths"]
def test_public_redoc_only_exposes_api_and_router_endpoints(tmp_path: Path) -> None:
def test_openapi_marks_generated_links_as_secret(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
schema = client.get("/openapi.json").json()
created = schema["components"]["schemas"]["HostnameCreateResponse"]["properties"]
status = schema["components"]["schemas"]["HostnameStatusResponse"]["properties"]
expected_public_paths = {
"/api/v1/hostnames/magic",
"/api/v1/management/{management_slug}",
"/api/v1/updates/{update_slug}",
"/healthz",
"/ip",
"/nic/update",
"/u/{update_slug}",
}
assert set(schema["paths"]) == expected_public_paths
for name in ["update_url", "dashboard_url", "registration_url"]:
assert "Secret" in created[name]["description"]
for name in ["update_url", "dashboard_url"]:
assert "Secret" in status[name]["description"]
def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
schema = client.get("/openapi.json").json()
credentials = schema["components"]["schemas"]["HostnameCredentials"]["properties"]
managed = schema["components"]["schemas"]["ManagedRecord"]["properties"]
for name in ["password", "update_url", "credentialed_update_url", "management_url"]:
assert "Secret" in credentials[name]["description"]
assert "Secret" in managed["update_url"]["description"]
@pytest.mark.parametrize(
("path", "payload"),
[
("/api/v1/hostnames/magic", {"username": "router", "unexpected": True}),
],
)
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.post(path, json=payload)
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "extra_forbidden"
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
def test_hostname_api_lifecycle_and_history(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
@@ -167,38 +141,165 @@ def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
)
client = TestClient(app)
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
created = client.post("/api/v1/hostnames")
assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
assert created.headers["x-robots-tag"] == "noindex, nofollow"
body = created.json()
assert body["hostname"].endswith(".ddns.example.net")
assert body["update_url"].startswith("http://ddns.example.net/u/")
assert body["management_url"].startswith("http://ddns.example.net/m/")
assert body["status"] == "pending"
assert body["update_url"].startswith("http://ddns.example.net/api/v1/router/update/")
assert body["dashboard_url"].startswith("http://ddns.example.net/m/")
assert body["registration_url"].startswith("http://ddns.example.net/api/v1/router/register/")
management_slug = body["management_url"].rsplit("/", 1)[1]
managed = client.get(f"/api/v1/management/{management_slug}")
assert managed.status_code == 200
assert managed.json()["hostname"] == body["hostname"]
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
update_path = _path(body["update_url"])
register_path = _path(body["registration_url"])
deleted = client.delete(f"/api/v1/management/{management_slug}")
pending = client.get(f"/api/v1/hostnames/{dashboard_secret}")
assert pending.status_code == 200
assert pending.json()["status"] == "pending"
assert pending.json()["history"] == []
assert client.get(update_path, params={"myip": "203.0.113.11"}).status_code == 404
registered = client.post(register_path, params={"myip": "203.0.113.10"})
assert registered.status_code == 200
assert registered.text == "good registered"
update = client.get(update_path, params={"myip": "203.0.113.11"})
assert update.status_code == 200
assert update.text == "good 203.0.113.11"
active = client.get(f"/api/v1/hostnames/{dashboard_secret}", params={"history_limit": 10})
assert active.status_code == 200
payload = active.json()
assert payload["status"] == "active"
assert payload["ipv4"] == "203.0.113.11"
assert payload["history"][0]["hostname"] == body["hostname"]
assert "source_ip" not in payload["history"][0]
deleted = client.delete(f"/api/v1/hostnames/{dashboard_secret}")
assert deleted.status_code == 204
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
assert client.get(f"/api/v1/hostnames/{dashboard_secret}").status_code == 404
def test_hostname_api_can_rotate_update_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
body = client.post("/api/v1/hostnames").json()
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
old_update_path = _path(body["update_url"])
pending_rotate = client.post(f"/api/v1/hostnames/{dashboard_secret}/rotate-update-url")
registered = client.post(_path(body["registration_url"]), params={"myip": "203.0.113.10"})
rotated = client.post(f"/api/v1/hostnames/{dashboard_secret}/rotate-update-url")
new_update_path = _path(rotated.json()["update_url"])
assert pending_rotate.status_code == 400
assert registered.status_code == 200
assert rotated.status_code == 200
assert rotated.json()["hostname"] == body["hostname"]
assert rotated.json()["update_url"] != body["update_url"]
assert client.get(old_update_path, params={"myip": "203.0.113.11"}).status_code == 404
assert client.get(new_update_path, params={"myip": "203.0.113.12"}).status_code == 200
def test_hostname_api_history_is_scoped_to_dashboard_secret(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
own = store.create_account("own.ddns.example.net", "own-router")
other = store.create_account("other.ddns.example.net", "other-router")
store.log_update_event("own.ddns.example.net", "203.0.113.21", None, "good", "updated", "198.51.100.10")
store.log_update_event("other.ddns.example.net", "203.0.113.22", None, "good", "updated", "198.51.100.11")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/hostnames/{own['management_slug']}", params={"history_limit": 10})
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
payload = response.json()
assert len(payload["history"]) == 1
assert payload["history"][0]["hostname"] == "own.ddns.example.net"
assert "source_ip" not in payload["history"][0]
assert "other.ddns.example.net" not in response.text
assert client.get(f"/api/v1/hostnames/{other['management_slug']}", params={"history_limit": 101}).status_code == 422
assert client.get(f"/api/v1/hostnames/{'a' * 32}").status_code == 404
def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
)
)
client = TestClient(app)
body = client.post("/api/v1/hostnames").json()
registered = client.post(_path(body["registration_url"]), params={"myip": "203.0.113.77"})
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}")
assert registered.status_code == 200
assert status.json()["status"] == "active"
assert status.json()["ipv4"] == "203.0.113.77"
assert len(published) == 1
assert published[0].hostname == body["hostname"]
assert published[0].ipv4 == "203.0.113.77"
def test_registration_requires_ip_when_provider_source_ip_is_not_public(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: None)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
)
)
client = TestClient(app)
body = client.post("/api/v1/hostnames").json()
registered = client.post(_path(body["registration_url"]))
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}")
assert registered.status_code == 400
assert registered.text.startswith("badip registration needs a public source IP")
assert status.json()["status"] == "pending"
def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.11"})
assert response.status_code == 200
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
assert response.text == "good 203.0.113.11"
assert len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
@@ -207,14 +308,14 @@ def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypa
def fail_publish(result, settings):
raise DnsProviderError("dns down")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
monkeypatch.setattr(ddns_module, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.11"})
assert response.status_code == 500
assert store.list_records() == []
@@ -232,8 +333,8 @@ def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
)
client = TestClient(app)
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
first = client.post("/api/v1/hostnames")
second = client.post("/api/v1/hostnames")
assert first.status_code == 201
assert second.status_code == 429
@@ -246,8 +347,8 @@ def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
assert response.status_code == 400
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
assert response.text == "badip"
assert store.list_records() == []
+108 -8
View File
@@ -3,27 +3,29 @@ import logging
import sqlite3
import time
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import router_dyndns.app as app_module
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
import karldns.app as app_module
from karldns import DdnsSettings, DdnsStore, make_app
from karldns.security import hash_lookup_token
def test_rate_limit_reject_does_not_increment_bucket(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
assert store.allow_rate_limit("198.51.100.1:/magic", 1) is True
assert store.allow_rate_limit("198.51.100.1:/magic", 1) is False
assert store.allow_rate_limit("198.51.100.1:/", 1) is True
assert store.allow_rate_limit("198.51.100.1:/", 1) is False
store.flush_rate_limits()
window = int(time.time()) // 60
with store.connect() as conn:
count = conn.execute(
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
("198.51.100.1:/magic", window),
("198.51.100.1:/", window),
).fetchone()[0]
assert count == 1
@@ -161,6 +163,52 @@ def test_cleanup_records_only_unused_hostname_count(tmp_path: Path) -> None:
assert set(store.list_cleanup_runs()[0]) == {"created_at", "unused_accounts_deleted"}
def test_cleanup_removes_pending_generated_setups_after_cutoff(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
with store.connect() as conn:
conn.execute(
"UPDATE accounts SET created_at = ? WHERE hostname = ?",
("2000-01-01T00:00:00+00:00", account["hostname"]),
)
result = store.cleanup(unused_account_hours=45 * 24)
assert result["unused_accounts"] == 1
assert store.get_account_by_slug(account["update_slug"]) is None
def test_cleanup_keeps_recent_pending_setup_until_pending_cutoff(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
recent = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
with store.connect() as conn:
conn.execute(
"UPDATE accounts SET created_at = ? WHERE hostname = ?",
(recent, account["hostname"]),
)
result = store.cleanup(unused_account_hours=1, pending_account_hours=24)
assert result["unused_accounts"] == 0
assert store.get_account_by_slug(account["update_slug"]) is not None
def test_pending_generated_setup_stays_pending_after_restart(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
store.close()
restarted = DdnsStore(database_path)
assert restarted.get_account_by_slug(account["update_slug"])["accepted_at"] is None
def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
@@ -194,7 +242,7 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
assert cache_size == -20_000
assert temp_store == 2
assert mmap_size == 256 * 1024 * 1024
assert user_version == 1
assert user_version == 3
assert {
"idx_accounts_created_at",
"idx_rate_limits_window",
@@ -203,6 +251,58 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
}.issubset(index_names)
def test_accounts_schema_migration_removes_legacy_router_credentials(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
with sqlite3.connect(database_path) as conn:
conn.execute("PRAGMA user_version = 2")
conn.execute(
"""
CREATE TABLE accounts (
hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE,
dashboard_password_hash TEXT,
created_at TEXT NOT NULL,
accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
INSERT INTO accounts(
hostname, username, token_hash, update_slug, management_hash,
dashboard_password_hash, created_at, accepted_at, disabled
)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0)
""",
(
"home.ddns.example.net",
"router",
"old-token-hash",
"update-secret",
hash_lookup_token("dashboard-secret"),
"2026-05-21T00:00:00+00:00",
"2026-05-21T00:00:00+00:00",
),
)
store = DdnsStore(database_path)
account = store.get_account_by_slug("update-secret")
with store.connect() as conn:
columns = {row[1] for row in conn.execute("PRAGMA table_info(accounts)")}
user_version = conn.execute("PRAGMA user_version").fetchone()[0]
assert user_version == 3
assert "username" not in columns
assert "token_hash" not in columns
assert account
assert account["hostname"] == "home.ddns.example.net"
def test_rate_limit_flush_requeues_counts_after_database_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
@@ -286,10 +386,10 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
store = DdnsStore(database_path)
store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
def fail_cleanup(unused_account_hours, event_history_hours):
def fail_cleanup(unused_account_hours, event_history_hours, pending_account_hours):
raise RuntimeError("cleanup exploded")
caplog.set_level(logging.ERROR, logger="router_dyndns.app")
caplog.set_level(logging.ERROR, logger="karldns.app")
monkeypatch.setattr(store, "cleanup", fail_cleanup)
asyncio.run(app_module._run_cleanup_if_due(store, settings))
+174 -4
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, make_app
from karldns import DdnsSettings, make_app
ROOT = Path(__file__).parents[1]
@@ -40,7 +40,7 @@ def test_updater_strips_fritzbox_placeholders_and_caches_ip(tmp_path: Path) -> N
)
)
client = TestClient(app)
account = client.post("/api/v1/hostnames/magic", json={}).json()
account = client.post("/api/v1/hostnames").json()
update_url = account["update_url"]
script = tmp_path / "curl"
log_file = tmp_path / "curl.log"
@@ -51,7 +51,6 @@ def test_updater_strips_fritzbox_placeholders_and_caches_ip(tmp_path: Path) -> N
f"""#!/bin/sh
printf '%s\\n' "$*" >> {log_file}
case "$*" in
*'/ip'*) printf '203.0.113.25\\n' ;;
*'<ipaddr>'*|*'<ip6addr>'*) printf 'bad placeholder leaked\\n'; exit 1 ;;
*'myip=203.0.113.25'*) printf 'good 203.0.113.25\\n' ;;
*) printf 'bad request\\n'; exit 1 ;;
@@ -67,9 +66,9 @@ esac
env={
"KARLDNS_UPDATE_URL": update_url,
"KARLDNS_CURL": str(script),
"KARLDNS_IP_URL": "https://karldns.de/ip",
"KARLDNS_CACHE_FILE": str(cache_file),
"KARLDNS_LOCK_FILE": str(lock_file),
"KARLDNS_IPV4": "203.0.113.25",
"KARLDNS_IPV6": "off",
},
)
@@ -77,3 +76,174 @@ esac
assert "good 203.0.113.25" in result.stdout
assert cache_file.read_text(encoding="utf-8").strip() == "203.0.113.25|"
assert "<ipaddr>" not in log_file.read_text(encoding="utf-8")
def test_installer_registers_generated_hostname_before_use(tmp_path: Path) -> None:
bin_dir = tmp_path / "bin"
home_dir = tmp_path / "home"
log_file = tmp_path / "curl.log"
bin_dir.mkdir()
home_dir.mkdir()
curl = bin_dir / "curl"
curl.write_text(
f"""#!/bin/sh
printf '%s\\n' "$*" >> {log_file}
output_file=""
previous=""
for argument in "$@"; do
if [ "$previous" = "-o" ]; then
output_file="$argument"
fi
previous="$argument"
done
case "$*" in
*'/karldns-update.sh'*)
cat > "$output_file" <<'EOS'
#!/bin/sh
printf 'updater placeholder\\n'
EOS
;;
*'/api/v1/hostnames'*)
printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}'
;;
*'/api/v1/router/register/manage-secret'*)
printf '%s\\n' 'good registered'
;;
*)
printf 'unexpected curl call: %s\\n' "$*" >&2
exit 1
;;
esac
""",
encoding="utf-8",
)
curl.chmod(0o700)
result = run_script(
"sh",
str(ROOT / "scripts" / "install-linux.sh"),
"--mode",
"user",
"--no-cron",
"--no-run",
env={
"HOME": str(home_dir),
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"KARLDNS_BASE_URL": "https://karldns.test",
"KARLDNS_RAW_BASE_URL": "https://raw.karldns.test",
},
)
log = log_file.read_text(encoding="utf-8")
assert "/api/v1/hostnames" in log
assert "/api/v1/router/register/manage-secret" in log
assert "KarlDNS: registering hostname" in result.stdout
config = home_dir / ".config" / "karldns" / "updater.env"
config_text = config.read_text(encoding="utf-8")
assert "KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/update-secret'" in config_text
assert "KARLDNS_HOSTNAME='abc.karldns.test'" in config_text
def test_user_installer_and_uninstaller_manage_cron_idempotently(tmp_path: Path) -> None:
bin_dir = tmp_path / "bin"
home_dir = tmp_path / "home"
log_file = tmp_path / "curl.log"
crontab_file = tmp_path / "crontab.txt"
bin_dir.mkdir()
home_dir.mkdir()
curl = bin_dir / "curl"
crontab = bin_dir / "crontab"
curl.write_text(
f"""#!/bin/sh
printf '%s\\n' "$*" >> {log_file}
output_file=""
previous=""
for argument in "$@"; do
if [ "$previous" = "-o" ]; then
output_file="$argument"
fi
previous="$argument"
done
case "$*" in
*'/karldns-update.sh'*)
cat > "$output_file" <<'EOS'
#!/bin/sh
printf 'updater placeholder\\n'
EOS
;;
*'/api/v1/hostnames'*)
printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}'
;;
*'/api/v1/router/register/manage-secret'*)
printf '%s\\n' 'good registered'
;;
*)
printf 'unexpected curl call: %s\\n' "$*" >&2
exit 1
;;
esac
""",
encoding="utf-8",
)
crontab.write_text(
f"""#!/bin/sh
if [ "$1" = "-l" ]; then
cat {crontab_file} 2>/dev/null || exit 1
exit 0
fi
cp "$1" {crontab_file}
""",
encoding="utf-8",
)
curl.chmod(0o700)
crontab.chmod(0o700)
env = {
"HOME": str(home_dir),
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"KARLDNS_BASE_URL": "https://karldns.test",
"KARLDNS_RAW_BASE_URL": "https://raw.karldns.test",
}
for _ in range(2):
run_script(
"sh",
str(ROOT / "scripts" / "install-linux.sh"),
"--mode",
"user",
"--no-run",
env=env,
)
cron_text = crontab_file.read_text(encoding="utf-8")
log = log_file.read_text(encoding="utf-8")
assert cron_text.count("# karldns-update start") == 1
assert "KARLDNS_CONFIG=" in cron_text
assert "karldns-update" in cron_text
assert log.count("/api/v1/hostnames") == 1
assert log.count("/api/v1/router/register/manage-secret") == 1
assert (home_dir / ".local" / "bin" / "karldns-update").exists()
run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", env=env)
assert "# karldns-update start" not in crontab_file.read_text(encoding="utf-8")
assert not (home_dir / ".local" / "bin" / "karldns-update").exists()
assert (home_dir / ".config" / "karldns" / "updater.env").exists()
run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", "--delete-config", env=env)
assert not (home_dir / ".config" / "karldns").exists()
assert not (home_dir / ".cache" / "karldns").exists()
def test_hosted_script_routes_redirect_to_github(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
for path, target in {
"/install.sh": "install-linux.sh",
"/uninstall.sh": "uninstall-linux.sh",
"/karldns-update.sh": "karldns-update.sh",
}.items():
response = client.get(path, follow_redirects=False)
assert response.status_code == 302
assert response.headers["location"].endswith(f"/scripts/{target}")
+31 -45
View File
@@ -6,10 +6,10 @@ from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
from router_dyndns.ddns_dns import validate_dns_provider_settings
from router_dyndns.ddns_security import DASHBOARD_UNLOCK_COOKIE
import karldns.services.ddns as ddns_module
from karldns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
from karldns.dns import validate_dns_provider_settings
from karldns.security import DASHBOARD_UNLOCK_COOKIE
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
@@ -20,14 +20,11 @@ def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "public-target.ddns.example.net",
"myip": "203.0.113.9",
},
f"/api/v1/router/update/{'a' * 43}",
params={"hostname": "public-target.ddns.example.net", "myip": "203.0.113.9"},
)
assert response.status_code == 401
assert response.status_code == 404
assert store.list_records() == []
@@ -39,16 +36,13 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "other.example.net",
"myip": "203.0.113.9",
"username": account["username"],
"password": account["password"],
},
f"/api/v1/router/update/{account['update_slug']}",
params={"hostname": "other.example.net", "myip": "203.0.113.9"},
)
assert response.status_code == 401
assert response.status_code == 200
assert store.list_records()[0]["hostname"] == "home.example.net"
assert store.get_account_by_hostname("other.example.net") is None
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
@@ -70,7 +64,7 @@ def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
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")
caplog.set_level(logging.INFO, logger="karldns.access")
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
@@ -81,30 +75,22 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path
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",
},
)
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.1"})
client.get(f"/api/v1/hostnames/{account['management_slug']}")
assert "/u/<secret>" in caplog.text
assert "/nic/update?<redacted>" in caplog.text
assert "/api/v1/router/update/<secret>" in caplog.text
assert "/api/v1/hostnames/<secret>" in caplog.text
assert account["update_slug"] not in caplog.text
assert account["password"] not in caplog.text
assert account["management_slug"] not in caplog.text
@pytest.mark.parametrize(
"path",
[
"/u/short",
"/api/v1/updates/short",
"/api/v1/router/update/short",
"/api/v1/router/register/short",
"/api/v1/hostnames/short",
"/m/short",
"/api/v1/management/short",
],
)
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
@@ -174,7 +160,7 @@ def test_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
)
client = TestClient(app)
created = client.post("/magic", data={"username": "router"})
created = client.post("/")
assert created.status_code == 200
assert created.headers["cache-control"] == "no-store"
@@ -192,7 +178,7 @@ def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
)
client = TestClient(app)
response = client.post("/magic", data={"username": "x" * 2000})
response = client.post("/", data={"username": "x" * 2000})
assert response.status_code == 413
@@ -208,7 +194,7 @@ def test_invalid_utf8_form_body_is_rejected(tmp_path: Path) -> None:
client = TestClient(app)
response = client.post(
"/magic",
"/",
content=b"username=\xff",
headers={"content-type": "application/x-www-form-urlencoded"},
)
@@ -229,7 +215,7 @@ def test_too_many_form_fields_are_rejected(tmp_path: Path) -> None:
body = "&".join(f"k{index}=v" for index in range(101))
response = client.post(
"/magic",
"/",
content=body.encode("ascii"),
headers={"content-type": "application/x-www-form-urlencoded"},
)
@@ -249,8 +235,8 @@ def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> N
)
client = TestClient(app)
first = client.post("/magic", data={"username": "alice"}, headers={"x-forwarded-for": "198.51.100.10"})
second = client.post("/magic", data={"username": "bob"}, headers={"x-forwarded-for": "198.51.100.11"})
first = client.post("/", headers={"x-forwarded-for": "198.51.100.10"})
second = client.post("/", headers={"x-forwarded-for": "198.51.100.11"})
assert first.status_code == 200
assert second.status_code == 429
@@ -260,8 +246,8 @@ def test_dns_delete_failure_is_logged_without_secret_management_slug(tmp_path: P
def fail_delete(hostname, settings):
raise DnsProviderError("dns down")
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
monkeypatch.setattr(ddns_service, "delete_dns_records", fail_delete)
caplog.set_level(logging.WARNING, logger="karldns.services.ddns")
monkeypatch.setattr(ddns_module, "delete_dns_records", fail_delete)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
@@ -363,7 +349,7 @@ 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
import karldns.dns.cloudflare as cloudflare
calls = {"find": 0}
@@ -394,7 +380,7 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
import dns.query
import dns.rcode
from router_dyndns.dns.rfc2136 import delete_rfc2136_records
from karldns.dns.rfc2136 import delete_rfc2136_records
class RefusedResponse:
def rcode(self) -> int:
+127 -83
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
from karldns import DdnsSettings, DdnsStore, make_app
def _csrf(html: str) -> str:
@@ -23,14 +23,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
)
client = TestClient(app)
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
response = client.post("/")
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Register URL:" in response.text
assert "Router fields" in response.text
assert "CNAME" in response.text
assert "Manual updates" in response.text
@@ -40,8 +38,8 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert "Public:" in response.text
assert "Secret:" in response.text
assert ".ddns.example.net" in response.text
assert "/u/" in response.text
assert "/nic/update?" in response.text
assert "/api/v1/router/update/" in response.text
assert "/api/v1/router/register/" in response.text
assert "/m/" in response.text
@@ -51,18 +49,18 @@ def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"})
assert response.status_code == 400
assert response.json()["detail"] == "custom hostnames are not supported; create a KarlDNS hostname and use a CNAME"
assert response.status_code == 404
assert app.state.store.count_accounts() == 0
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
@@ -73,15 +71,16 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
)
client = TestClient(app)
response = client.post("/magic")
response = client.post("/")
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Register URL:" in response.text
assert "Private dashboard:" in response.text
assert "Pending setup" in response.text
assert "Register hostname" in response.text
assert "Unregistered setups are removed after 24 hours." in response.text
assert "Router fields" in response.text
assert "CNAME" in response.text
assert "FRITZ!Box fills in" in response.text
@@ -91,17 +90,64 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
assert ".ddns.example.net" in response.text
def test_magic_get_redirects_to_home_form(tmp_path: Path) -> None:
def test_public_examples_use_visitor_public_ip(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="https://karldns.de",
)
)
client = TestClient(app, client=("8.8.8.8", 12345))
home = client.get("/")
created = client.post("/")
assert home.status_code == 200
assert created.status_code == 200
assert "myip=8.8.8.8" in home.text
assert "good 8.8.8.8" in home.text
assert "myip=8.8.8.8" in created.text
def test_generated_hostname_must_be_activated_before_updates_work(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
created = client.post("/")
management_slug = re.search(r'action="/m/([^"]+)/activate"', created.text).group(1)
account = app.state.store.get_account_by_management_slug(management_slug)
assert account["accepted_at"] is None
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
activated = client.post(f"/m/{management_slug}/activate", data={"csrf": _csrf(created.text)})
assert activated.status_code == 200
assert "Hostname registered" in activated.text
assert app.state.store.get_account_by_management_slug(management_slug)["accepted_at"]
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 200
def test_old_magic_route_is_gone(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/magic", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/#create"
assert response.status_code == 404
def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
@@ -113,8 +159,6 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
assert '<link href="/admin.css?v=2" rel="stylesheet">' in management.text
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Router fields" in management.text
assert "CNAME" in management.text
assert "Manual updates" in management.text
@@ -126,7 +170,7 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)})
assert deleted.status_code == 200
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
update = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 404
@@ -170,22 +214,10 @@ def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> N
assert "Update-URL:" not in locked.text
assert "correct horse" not in locked.text
update = locked_client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
update = locked_client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 200
assert update.text == "good 203.0.113.9"
nic_update = locked_client.get(
"/nic/update",
params={
"hostname": account["hostname"],
"myip": "203.0.113.10",
"username": account["username"],
"password": account["password"],
},
)
assert nic_update.status_code == 200
assert nic_update.text == "good 203.0.113.10"
bad_unlock = locked_client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
@@ -333,7 +365,7 @@ def test_existing_dashboard_password_requires_current_password_to_change_or_remo
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path: Path) -> None:
def test_management_dashboard_rotates_update_url(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
@@ -349,7 +381,6 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
dashboard = client.get(f"/m/{account['management_slug']}")
assert dashboard.status_code == 200
assert "New update URL" in dashboard.text
assert "New password" in dashboard.text
assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text
@@ -357,26 +388,10 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
assert rotated_update.status_code == 200
assert "Update URL changed. Update your router now." in rotated_update.text
assert "data-secret-input" in rotated_update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
rotated_password = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(rotated_update.text), "action": "password"})
assert rotated_password.status_code == 200
assert "Router password changed" in rotated_password.text
assert "Full update URL:" in rotated_password.text
assert "Kennwort (optional):" in rotated_password.text
assert "data-secret-toggle" in rotated_password.text
assert client.get(
"/nic/update",
params={
"hostname": "home.ddns.example.net",
"username": "router",
"password": account["password"],
"myip": "203.0.113.9",
},
).status_code == 401
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)}
assert "dashboard_update_url_rotated" in statuses
assert "dashboard_router_credentials_rotated" in statuses
assert "dashboard_router_credentials_rotated" not in statuses
def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None:
@@ -393,8 +408,8 @@ def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -
assert rotated.status_code == 200
assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"]
assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"]
assert client.get(f"/u/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
assert client.get(f"/u/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
assert client.get(f"/api/v1/router/update/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
assert client.get(f"/api/v1/router/update/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None:
@@ -437,30 +452,6 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/admin"' not in response.text
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "You get a hostname, a private update URL, and router fields if your router needs them." in response.text
assert "Add a CNAME" in response.text
assert "Linux cron updater" in response.text
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
assert "--delete-config" in response.text
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="karldns.de",
public_base_url="https://karldns.de",
)
)
@@ -469,11 +460,61 @@ def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) ->
response = client.get("/")
assert response.status_code == 200
assert "https://karldns.de/u/&lt;secret&gt;" in response.text
assert 'href="/admin"' not in response.text
assert 'href="#create"' not in response.text
assert response.text.count(">Create hostname<") == 1
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "Create a hostname, save the private values, register it, done." in response.text
assert "Random hostname and private links." in response.text
assert "Activate the hostname when you are ready." in response.text
assert "home.example.com. CNAME" not in response.text
assert "Linux cron updater" in response.text
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
assert "User install" not in response.text
assert "--delete-config" not in response.text
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="karldns.de",
public_base_url="https://ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "https://ddns.example.net/api/v1/router/update/&lt;secret&gt;" in response.text
assert "https://karldns.de/api/v1/router/update/&lt;secret&gt;" not in response.text
assert "http://127.0.0.1" not in response.text
assert "http://localhost" not in response.text
def test_public_examples_use_karldns_domain_and_no_shell_prompt(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "https://karldns.de/api/v1/router/update/&lt;secret&gt;" in response.text
assert "http://127.0.0.1:8099/api/v1/router/update/&lt;secret&gt;" not in response.text
assert "$ curl" not in response.text
assert 'data-copy="curl -sS &#x27;https://karldns.de/api/v1/router/update/&lt;secret&gt;?myip=203.0.113.10&#x27;"' in response.text
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
@@ -485,7 +526,7 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert '<script src="/clipboard.js?v=3" 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=5" rel="stylesheet">' in response.text
assert '<link href="/app.css?v=12" 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
@@ -534,6 +575,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
assert "Made with" in response.text
assert "❤️" in response.text
assert "by karl" in response.text
assert response.text.count('class="lucide-icon footer-icon"') == 5
assert 'class="lucide-icon theme-icon theme-icon-moon"' in response.text
assert 'class="lucide-icon copy-icon"' in response.text
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text
assert 'href="https://karlcom.de"' in response.text
@@ -546,7 +590,7 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
assert "WebPage" in privacy.text
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
assert "protected router passwords" in privacy.text
assert "private update links" in privacy.text
assert "Made with" in privacy.text
assert "❤️" in privacy.text
assert "by karl" in privacy.text
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more