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
+3364 -2018

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ DDNS_TTL=60
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20 # DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
# DDNS_MAX_REQUEST_BODY_BYTES=16384 # DDNS_MAX_REQUEST_BODY_BYTES=16384
# DDNS_REDACTED_ACCESS_LOG=0 # DDNS_REDACTED_ACCESS_LOG=0
# DDNS_CLEANUP_PENDING_ACCOUNT_HOURS=24
# DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080 # DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
# DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320 # DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320
# DDNS_CLEANUP_INTERVAL_SECONDS=3600 # DDNS_CLEANUP_INTERVAL_SECONDS=3600
+1
View File
@@ -14,3 +14,4 @@ build/
tmp/ tmp/
backend_audit_report.md backend_audit_report.md
security_best_practices_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 ENV DDNS_DATABASE=/data/ddns.sqlite3
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md /app/ COPY pyproject.toml README.md requirements.txt /app/
COPY router_dyndns /app/router_dyndns COPY karldns /app/karldns
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir -r requirements.txt . \
RUN adduser --system --group --home /app karldns \ && addgroup -S karldns \
&& adduser -S -G karldns -h /app karldns \
&& mkdir -p /data \ && mkdir -p /data \
&& chown -R karldns:karldns /app /data && chown -R karldns:karldns /app /data
USER karldns 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? ## Why KarlDNS?
- **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure. - **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. - **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. - **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. - **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. - **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 ## 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 ## Logo And Social Preview
@@ -31,12 +31,12 @@ KarlDNS ships web-ready brand assets so self-hosted instances and shared links l
| Asset | Purpose | | Asset | Purpose |
| --- | --- | | --- | --- |
| `router_dyndns/assets/logo-nav.webp` | Small navigation logo. | | `karldns/assets/logo-nav.webp` | Small navigation logo. |
| `router_dyndns/assets/logo.webp` | Optimized homepage logo. | | `karldns/assets/logo.webp` | Optimized homepage logo. |
| `router_dyndns/assets/logo-192.png` and `logo-512.png` | PWA, app icon, and manifest icons. | | `karldns/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. | | `karldns/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. | | `karldns/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/social-preview.webp` | Optimized WebP social preview for modern clients. |
## What It Does ## What It Does
@@ -44,10 +44,11 @@ KarlDNS lets you host your own managed DynDNS service:
1. A user opens your KarlDNS URL. 1. A user opens your KarlDNS URL.
2. They generate a random hostname like `a1b2c3d4.karldns.de`. 2. They generate a random hostname like `a1b2c3d4.karldns.de`.
3. KarlDNS shows the exact router settings for the DynDNS form. 3. KarlDNS shows the exact router settings and one-time secrets.
4. The router periodically calls the update URL with its current public IPv4/IPv6 address. 4. The user confirms they saved the values, which registers the hostname.
5. KarlDNS publishes the matching DNS records through your configured DNS backend. 5. The router periodically calls the update URL with its current public IPv4/IPv6 address.
6. If the user wants their own DNS name, they can create a CNAME to the generated hostname. 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. 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 | | 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 records | A and AAAA |
| DNS providers | Cloudflare API and RFC 2136 dynamic DNS | | DNS providers | Cloudflare API and RFC 2136 dynamic DNS |
| Generated hostnames | Random provider-owned names that can be used directly or as CNAME targets | | 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 | | 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 | | 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 | | 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 | | Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example |
@@ -100,12 +101,12 @@ Open:
- Web UI: `http://localhost:8080/` - Web UI: `http://localhost:8080/`
- Admin UI: `http://localhost:8080/admin` - Admin UI: `http://localhost:8080/admin`
- Swagger UI: `http://localhost:8080/docs` - API guide: `http://localhost:8080/docs`
- ReDoc: `http://localhost:8080/redoc` - 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`. 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 ## Production Setup
@@ -144,35 +145,23 @@ export DDNS_TTL=60
## FRITZ!Box DynDNS Setup ## 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`. 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`.
KarlDNS also shows optional compatibility fields for routers that insist on separate credentials:
- `Update-URL` - `Update-URL`
- `Domainnamen` - `Domainnamen`
- `CNAME target` - `CNAME target`
- `Benutzername` (optional) - `Private dashboard`
- `Kennwort` (optional) - `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: The generated FRITZ!Box URL uses the native placeholders:
```text ```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`. 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`.
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.
## Manual Updates And Automation ## Manual Updates And Automation
@@ -180,24 +169,26 @@ Every generated hostname can be updated with curl, cron, a shell script, Home As
```bash ```bash
# Explicit IPv4 update # 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 # 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 # Use the request source IP
curl -sS 'https://karldns.de/u/<random-update-slug>' curl -sS 'https://karldns.de/api/v1/router/update/<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'
``` ```
### Linux Cron Installer ### 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: 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 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: Use an existing KarlDNS update URL:
```bash ```bash
curl -fsSL https://karldns.de/install.sh | sudo sh -s -- \ 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' --hostname 'home.karldns.de'
``` ```
@@ -229,23 +214,29 @@ Common options:
```bash ```bash
--every 5 # cron interval in minutes, default 5 --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 --ipv4 auto # auto, off, or a static IPv4
--ipv6 auto # auto, off, or a static IPv6 --ipv6 auto # auto, off, or a static IPv6
--no-cron # install files only --no-cron # install files only
--no-run # skip the first update --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: Installed files in system mode:
```text ```text
/usr/local/bin/karldns-update /usr/local/bin/karldns-update
/etc/karldns/updater.env /etc/karldns/updater.env
/etc/cron.d/karldns-updater
/var/cache/karldns/last-ip /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: Installed files in user mode:
```text ```text
@@ -254,7 +245,7 @@ Installed files in user mode:
~/.cache/karldns/last-ip ~/.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: 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 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 ```bash
curl -fsSL https://karldns.de/uninstall.sh | sudo sh -s -- --delete-config 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. 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. 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. 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 ## 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 | | Endpoint | Purpose |
| --- | --- | | --- | --- |
| `POST /api/v1/hostnames/magic` | Create an anonymous provider-owned hostname. | | `POST /api/v1/hostnames` | Create an anonymous provider-owned hostname in pending state. |
| `GET /api/v1/management/{management_slug}` | Inspect a hostname by private management link. | | `GET /api/v1/hostnames/{dashboard_secret}` | Inspect a hostname and recent update history by private dashboard secret. |
| `DELETE /api/v1/management/{management_slug}` | Delete a hostname and its DNS records. | | `POST /api/v1/hostnames/{dashboard_secret}/rotate-update-url` | Replace the private router update URL. |
| `GET /api/v1/updates/{update_slug}` | JSON update endpoint for routers or automation. | | `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: Example:
```bash ```bash
curl -sS https://karldns.de/api/v1/hostnames/magic \ curl -sS -X POST https://karldns.de/api/v1/hostnames
-H 'content-type: application/json' \ ```
-d '{"username":"home-router"}'
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 ## 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 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. 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 long random value for `DDNS_ADMIN_PASSWORD`.
- Use a separate long random value for `DDNS_DASHBOARD_COOKIE_SECRET` on public instances. - Use a separate long random value for `DDNS_DASHBOARD_COOKIE_SECRET` on public instances.
- Change `DDNS_ADMIN_PATH` if you do not want the operator UI at `/admin`. - Change `DDNS_ADMIN_PATH` if you do not want the operator UI at `/admin`.
- 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. - Use a least-privilege DNS API token or TSIG key.
- Set `DDNS_TRUSTED_HOSTS` only when the app should answer for hostnames beyond `DDNS_PUBLIC_BASE_URL`. - Set `DDNS_TRUSTED_HOSTS` only when the app should answer for hostnames beyond `DDNS_PUBLIC_BASE_URL`.
- Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers. - 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. - Leave cleanup enabled. The cleanup scheduler is stored in SQLite with a task row, next run time, status, last error, and a database lease so only one app process runs a due cleanup job.
- Back up `/data/ddns.sqlite3`. - Back up `/data/ddns.sqlite3`.
- Run one Uvicorn worker with SQLite unless you have tested your deployment under load. Scheduled cleanup is lease-protected across workers, but rate-limit state and per-host update locks are process-local. - Run one Uvicorn worker with SQLite unless you have tested your deployment under load. Scheduled cleanup is lease-protected across workers, but rate-limit state and per-host update locks are process-local.
@@ -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_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_ADMIN_RATE_LIMIT_PER_MINUTE` | `20` | Admin/operator requests allowed per source IP and path. |
| `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. | | `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. |
| `DDNS_ADMIN_PATH` | `/admin` | Private operator UI path. Admin routes are hidden from OpenAPI and require Basic auth. | | `DDNS_ADMIN_PATH` | `/admin` | Private operator UI path. Admin 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_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_REDACTED_ACCESS_LOG` | `0` | Enable KarlDNS request logs with bearer paths and update query strings redacted. |
| `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. | | `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. |
| `DDNS_CLEANUP_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_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_CLEANUP_INTERVAL_SECONDS` | `3600` | Store the cleanup task cadence in SQLite and schedule the next cleanup run at this interval. |
| `DDNS_DASHBOARD_UNLOCK_SECONDS` | `86400` | Default dashboard password unlock cookie lifetime. | | `DDNS_DASHBOARD_UNLOCK_SECONDS` | `86400` | Default dashboard password unlock cookie lifetime. |
@@ -375,22 +390,21 @@ Backend layout:
| Module | Purpose | | Module | Purpose |
| --- | --- | | --- | --- |
| `router_dyndns/app.py` | FastAPI app factory, lifespan, middleware and router registration. | | `karldns/app.py` | FastAPI app factory, lifespan, middleware and router registration. |
| `router_dyndns/ddns.py` | Compatibility exports for existing imports and CLI users. | | `karldns/settings.py` | Pydantic settings loaded from environment variables. |
| `router_dyndns/routers/` | Module-level `APIRouter` files for API, public web, management, admin, assets, and DynDNS update routes. | | `karldns/models.py` | Shared domain models such as update results. |
| `router_dyndns/dependencies.py` | Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. | | `karldns/routers/` | Module-level `APIRouter` files for API, public web, management, admin, assets, and DynDNS update routes. |
| `router_dyndns/schemas.py` | Pydantic request and response models used by the JSON API. | | `karldns/dependencies.py` | Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. |
| `router_dyndns/web/` | HTML rendering, form parsing, and security-header/body-limit support code. | | `karldns/schemas.py` | Pydantic request and response models used by the JSON API. |
| `router_dyndns/ddns_service.py` | Domain logic for account creation and IP updates. | | `karldns/web/` | Layout, reusable HTML components, scripts, CSS, form parsing, and security-header/body-limit support code. |
| `router_dyndns/store/` | SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. | | `karldns/services/ddns.py` | Domain logic for account creation and IP updates. |
| `router_dyndns/ddns_store.py` | Compatibility export for `DdnsStore`. | | `karldns/store/` | SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. |
| `router_dyndns/dns/` | Cloudflare and RFC 2136 DNS publishing. | | `karldns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
| `router_dyndns/ddns_dns.py` | Compatibility exports for DNS provider helpers. |
```bash ```bash
ruff check router_dyndns tests ruff check karldns tests
pytest pytest
python -m compileall router_dyndns python -m compileall karldns
``` ```
## SEO Keywords ## 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`. - 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`. - 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`. - 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 ## 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: 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`. 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`. 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. 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.concurrency import run_in_threadpool
from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.middleware.trustedhost import TrustedHostMiddleware
from .ddns_dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns from .dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
from .ddns_models import DdnsSettings, UpdateResult from .models import UpdateResult
from .ddns_service import DdnsService
from .ddns_store import DdnsStore
from .routers import admin, api, assets, management, public, updates 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 from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware
__all__ = [ __all__ = [
@@ -37,12 +38,12 @@ OPENAPI_TAGS = [
"description": "Create KarlDNS hostnames that can be used directly or as CNAME targets.", "description": "Create KarlDNS hostnames that can be used directly or as CNAME targets.",
}, },
{ {
"name": "updates", "name": "router",
"description": "Router-facing IP update endpoints for FRITZ!Box and compatible clients.", "description": "Router-facing registration and IP update endpoints for simple URL clients.",
}, },
{ {
"name": "management", "name": "admin",
"description": "API management through private dashboard links. Dashboard passwords protect only the web dashboard, not these API routes.", "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." "for KarlDNS-managed hostnames through Cloudflare or RFC 2136."
), ),
version="0.1.0", version="0.1.0",
docs_url="/docs", docs_url=None,
redoc_url="/redoc", redoc_url=None,
openapi_tags=OPENAPI_TAGS, openapi_tags=OPENAPI_TAGS,
lifespan=lifespan, lifespan=lifespan,
) )
@@ -135,6 +136,7 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None:
store.cleanup, store.cleanup,
settings.cleanup_unused_account_hours, settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours, settings.cleanup_event_history_hours,
settings.cleanup_pending_account_hours,
) )
except Exception as exc: except Exception as exc:
LOGGER.exception("Scheduled cleanup failed") 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 import argparse
from .ddns import DdnsSettings, make_app from .app import make_app
from .settings import DdnsSettings
def main() -> None: 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 = 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("--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") render_url.add_argument("--include-ipv6", action="store_true")
serve = subparsers.add_parser("serve", help="Run the KarlDNS web service.") serve = subparsers.add_parser("serve", help="Run the KarlDNS web service.")
@@ -25,7 +26,7 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
if args.command == "render-fritz-url": 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": elif args.command == "serve":
import uvicorn import uvicorn
@@ -37,12 +38,12 @@ def main() -> None:
uvicorn.run(make_app(settings), host=args.host, port=args.port, access_log=False) 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("/") 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: if include_ipv6:
url += "&myipv6=<ip6addr>" url += "&myipv6=<ip6addr>"
return url.replace("<domain>", host) return url
if __name__ == "__main__": if __name__ == "__main__":
@@ -3,7 +3,7 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from .ddns_models import DdnsSettings from .settings import DdnsSettings
def settings_from_env() -> DdnsSettings: def settings_from_env() -> DdnsSettings:
@@ -27,6 +27,7 @@ def settings_from_env() -> DdnsSettings:
require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"), require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"),
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""), cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""), 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_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_event_history_hours=_env_int("DDNS_CLEANUP_EVENT_HISTORY_HOURS", 24 * 180),
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600), cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
@@ -3,14 +3,14 @@ from __future__ import annotations
import secrets import secrets
from typing import Annotated 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 fastapi.responses import PlainTextResponse
from .ddns_dns import DnsProviderError from .dns import DnsProviderError
from .ddns_models import DdnsSettings from .security import credentials_from_basic_auth
from .ddns_security import credentials_from_basic_auth from .services.ddns import BadIpError, DdnsService
from .ddns_service import BadIpError, DdnsService from .settings import DdnsSettings
from .ddns_store import DdnsStore from .store import DdnsStore
def get_settings(request: Request) -> DdnsSettings: def get_settings(request: Request) -> DdnsSettings:
@@ -30,20 +30,6 @@ StoreDep = Annotated[DdnsStore, Depends(get_store)]
ServiceDep = Annotated[DdnsService, Depends(get_service)] 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( def authenticate_admin(
request: Request, request: Request,
authorization: Annotated[str | None, Header()] = None, authorization: Annotated[str | None, Header()] = None,
File renamed without changes.
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from ..ddns_models import DdnsSettings, UpdateResult from ..models import UpdateResult
from ..settings import DdnsSettings
SUPPORTED_DNS_PROVIDERS = {"", "cloudflare", "rfc2136"} SUPPORTED_DNS_PROVIDERS = {"", "cloudflare", "rfc2136"}
@@ -5,7 +5,8 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from ..ddns_models import DdnsSettings, UpdateResult from ..models import UpdateResult
from ..settings import DdnsSettings
from .base import DnsProviderError, DnsReconciliationError from .base import DnsProviderError, DnsReconciliationError
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from ..ddns_models import DdnsSettings, UpdateResult from ..models import UpdateResult
from ..settings import DdnsSettings
from .base import DnsProviderError from .base import DnsProviderError
@@ -10,10 +10,9 @@ DASHBOARD_PASSWORD_CHANGED = "dashboard_password_changed"
DASHBOARD_PASSWORD_ENABLED = "dashboard_password_enabled" DASHBOARD_PASSWORD_ENABLED = "dashboard_password_enabled"
DASHBOARD_PASSWORD_REMOVED = "dashboard_password_removed" DASHBOARD_PASSWORD_REMOVED = "dashboard_password_removed"
DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated" DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated"
DASHBOARD_ROUTER_CREDENTIALS_ROTATED = "dashboard_router_credentials_rotated"
DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted" DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted"
DASHBOARD_HOSTNAME_ACTIVATED = "dashboard_hostname_activated"
ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted" ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted"
ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated" ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated"
ADMIN_MANAGEMENT_URL_ROTATED = "admin_management_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 fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool 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 ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
from ..dns import DnsProviderError
from ..event_statuses import ( from ..event_statuses import (
ADMIN_HOSTNAME_DELETED, ADMIN_HOSTNAME_DELETED,
ADMIN_MANAGEMENT_URL_ROTATED, ADMIN_MANAGEMENT_URL_ROTATED,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
ADMIN_UPDATE_URL_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.forms import first_form_value, request_form
from ..web.templates import _copy_row
from ..web.templates_admin import _admin_secret_panel, _render_admin_page from ..web.templates_admin import _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100 ADMIN_TABLE_LIMIT = 100
@@ -24,8 +23,6 @@ ADMIN_JSON_OFFSET_CAP = 10_000
def make_router(admin_path: str = "/admin") -> APIRouter: def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter() router = APIRouter()
records_path = f"{admin_path}/records"
events_path = f"{admin_path}/events"
def render_admin( def render_admin(
service: ServiceDep, 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( async def admin_delete_account(
request: Request, request: Request,
settings: SettingsDep, settings: SettingsDep,
@@ -101,7 +98,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
) )
return RedirectResponse(admin_path, status_code=303) 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( async def admin_rotate_account(
request: Request, request: Request,
settings: SettingsDep, settings: SettingsDep,
@@ -129,7 +126,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"router update URL rotated by admin", "router update URL rotated by admin",
client_ip(request, settings), client_ip(request, settings),
) )
notice = _admin_secret_panel("Update URL 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": elif action == "management":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname) rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool( await run_in_threadpool(
@@ -138,31 +135,10 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
None, None,
None, None,
ADMIN_MANAGEMENT_URL_ROTATED, ADMIN_MANAGEMENT_URL_ROTATED,
"management URL rotated by admin", "dashboard link rotated by admin",
client_ip(request, settings), client_ip(request, settings),
) )
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.magic_management_url(rotated))]) if rotated else None notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")]) 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"),
],
)
else: else:
raise HTTPException(status_code=400, detail="invalid rotation action") raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice) 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, store.cleanup,
settings.cleanup_unused_account_hours, settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours, settings.cleanup_event_history_hours,
settings.cleanup_pending_account_hours,
) )
notice = _admin_secret_panel( notice = _admin_secret_panel(
"Cleanup done", "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) page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
return HTMLResponse(page) 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 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 typing import NoReturn
from fastapi import APIRouter, HTTPException, Request, Response 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 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 ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..dns import DnsProviderError
from ..event_statuses import ( from ..event_statuses import (
DASHBOARD_HOSTNAME_ACTIVATED,
DASHBOARD_HOSTNAME_DELETED, DASHBOARD_HOSTNAME_DELETED,
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
DASHBOARD_UPDATE_URL_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.forms import first_form_value, request_form
from ..web.html_utils import trusted from ..web.html_utils import trusted
from ..web.templates import _page from ..web.layout import _page
from ..web.templates_management import _render_management_page, _render_management_unlock_page from ..web.templates_management import (
_render_management_page,
_render_management_unlock_page,
_render_pending_management_page,
)
from . import management_passwords from . import management_passwords
from .management_shared import ( from .management_shared import (
get_active_management_account, get_active_management_account,
get_active_management_account_async, get_active_management_account_async,
has_management_access, has_management_access,
locked_management_response, locked_management_response,
management_account_is_accepted,
render_management_page_async, render_management_page_async,
) )
from .path_params import ManagementSlugPath 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) @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) account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account) csrf_token = dashboard_csrf_token(management_slug, account)
if not 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): if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token) return _render_management_unlock_page(management_slug, account, csrf_token)
return _render_management_page(service, settings, management_slug, account, csrf_token) 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) @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, management_slug: ManagementSlugPath,
request: Request, request: Request,
settings: SettingsDep, settings: SettingsDep,
@@ -50,12 +122,18 @@ async def rotate_magic_credentials(
service: ServiceDep, service: ServiceDep,
) -> str: ) -> str:
account = await get_active_management_account_async(store, management_slug) account = await get_active_management_account_async(store, management_slug)
if not 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): if not has_management_access(request, settings, management_slug, account):
return locked_management_response(management_slug, account) return locked_management_response(management_slug, account)
form = await request_form(request, settings) form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf")) require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"]) hostname = str(account["hostname"])
action = first_form_value(form, "action") action = first_form_value(form, "action")
request_ip = client_ip(request, settings)
if action == "update": if action == "update":
rotated = await run_in_threadpool(store.rotate_update_slug, hostname) rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
if not rotated: if not rotated:
@@ -67,7 +145,7 @@ async def rotate_magic_credentials(
None, None,
DASHBOARD_UPDATE_URL_ROTATED, DASHBOARD_UPDATE_URL_ROTATED,
"router update URL rotated", "router update URL rotated",
client_ip(request, settings), request_ip,
) )
return await render_management_page_async( return await render_management_page_async(
service, service,
@@ -76,35 +154,13 @@ async def rotate_magic_credentials(
rotated, rotated,
dashboard_csrf_token(management_slug, rotated), dashboard_csrf_token(management_slug, rotated),
message="Update URL changed. Update your router now.", message="Update URL changed. Update your router now.",
) request_ip=request_ip,
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,
) )
raise _bad_rotation_action() raise _bad_rotation_action()
@router.post("/m/{management_slug}/delete", include_in_schema=False) @router.post("/m/{management_slug}/delete", include_in_schema=False)
async def delete_magic(management_slug: 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) account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account): if not has_management_access(request, settings, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401) return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
@@ -4,9 +4,9 @@ from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool 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 ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED 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.forms import first_form_value, request_form
from ..web.templates_management import _render_management_unlock_page from ..web.templates_management import _render_management_unlock_page
from .management_shared import ( from .management_shared import (
@@ -24,7 +24,7 @@ router = APIRouter()
@router.post("/m/{management_slug}/unlock", include_in_schema=False) @router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_magic(management_slug: 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) account = await get_active_management_account_async(store, management_slug)
if not account.get("dashboard_password_hash"): if not account.get("dashboard_password_hash"):
return RedirectResponse(f"/m/{management_slug}", status_code=303) return RedirectResponse(f"/m/{management_slug}", status_code=303)
@@ -70,7 +70,14 @@ async def set_dashboard_password(
first_form_value(form, "current_dashboard_password"), first_form_value(form, "current_dashboard_password"),
) )
if not is_current: if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current 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") password = first_form_value(form, "dashboard_password")
confirm = first_form_value(form, "dashboard_password_confirm") confirm = first_form_value(form, "dashboard_password_confirm")
if len(password) < 8: if len(password) < 8:
@@ -81,6 +88,7 @@ async def set_dashboard_password(
account, account,
dashboard_csrf_token(management_slug, account), dashboard_csrf_token(management_slug, account),
message="Password must be at least 8 characters.", message="Password must be at least 8 characters.",
request_ip=client_ip(request, settings),
) )
return HTMLResponse(page, status_code=400) return HTMLResponse(page, status_code=400)
if password != confirm: if password != confirm:
@@ -91,6 +99,7 @@ async def set_dashboard_password(
account, account,
dashboard_csrf_token(management_slug, account), dashboard_csrf_token(management_slug, account),
message="The passwords do not match.", message="The passwords do not match.",
request_ip=client_ip(request, settings),
) )
return HTMLResponse(page, status_code=400) return HTMLResponse(page, status_code=400)
had_password = bool(account.get("dashboard_password_hash")) had_password = bool(account.get("dashboard_password_hash"))
@@ -131,7 +140,14 @@ async def remove_dashboard_password(
first_form_value(form, "current_dashboard_password"), first_form_value(form, "current_dashboard_password"),
) )
if not is_current: if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current 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.clear_dashboard_password, hostname)
await run_in_threadpool( await run_in_threadpool(
store.log_update_event, store.log_update_event,
@@ -4,16 +4,16 @@ from fastapi import HTTPException, Request, Response
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from ..ddns_models import DdnsSettings from ..security import (
from ..ddns_security import (
DASHBOARD_UNLOCK_COOKIE, DASHBOARD_UNLOCK_COOKIE,
dashboard_cookie_signing_secret, dashboard_cookie_signing_secret,
dashboard_csrf_token, dashboard_csrf_token,
sign_dashboard_unlock_cookie, sign_dashboard_unlock_cookie,
verify_dashboard_unlock_cookie, verify_dashboard_unlock_cookie,
) )
from ..ddns_service import DdnsService from ..services.ddns import DdnsService
from ..ddns_store import DdnsStore from ..settings import DdnsSettings
from ..store import DdnsStore
from ..store.types import AccountRow from ..store.types import AccountRow
from ..web.templates_management import _render_management_page, _render_management_unlock_page 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) 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: def has_management_access(request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount) -> bool:
password_hash = account.get("dashboard_password_hash") password_hash = account.get("dashboard_password_hash")
if not password_hash: if not password_hash:
@@ -54,9 +58,18 @@ def management_error_response(
management_slug: str, management_slug: str,
account: ManagementAccount, account: ManagementAccount,
message: str, message: str,
request_ip: str | None = None,
) -> HTMLResponse: ) -> HTMLResponse:
return 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, status_code=400,
) )
@@ -67,8 +80,9 @@ async def management_error_response_async(
management_slug: str, management_slug: str,
account: ManagementAccount, account: ManagementAccount,
message: str, message: str,
request_ip: str | None = None,
) -> HTMLResponse: ) -> 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( async def render_management_page_async(
@@ -79,6 +93,7 @@ async def render_management_page_async(
csrf_token: str, csrf_token: str,
message: str | None = None, message: str | None = None,
created_account: dict[str, str] | None = None, created_account: dict[str, str] | None = None,
request_ip: str | None = None,
) -> str: ) -> str:
return await run_in_threadpool( return await run_in_threadpool(
_render_management_page, _render_management_page,
@@ -89,6 +104,7 @@ async def render_management_page_async(
csrf_token, csrf_token,
message, message,
created_account, 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 fastapi import HTTPException, Request
from .ddns_models import DdnsSettings from .settings import DdnsSettings
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock" DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60 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: def is_rate_limited_path(path: str) -> bool:
limited_paths = { return path == "/" or path.startswith(("/m/", "/api/v1/hostnames", "/api/v1/router/"))
"/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/")
)
def rate_limit_bucket(path: str) -> str: 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): if path.startswith(prefix):
return prefix.rstrip("/") return prefix.rstrip("/")
return path return path
File renamed without changes.
@@ -12,11 +12,12 @@ from dataclasses import dataclass
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns from ..dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
from .ddns_models import DdnsSettings, UpdateResult from ..event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED
from .ddns_security import client_ip from ..models import UpdateResult
from .ddns_store import DdnsStore from ..security import client_ip
from .event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED from ..settings import DdnsSettings
from ..store import DdnsStore
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -39,12 +40,12 @@ class DdnsService:
self.store = store self.store = store
self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)] 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: if not self.settings.hostname_suffix:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required") raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
for _ in range(5): for _ in range(5):
try: 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: except sqlite3.IntegrityError:
continue continue
raise HTTPException(status_code=500, detail="could not allocate hostname") raise HTTPException(status_code=500, detail="could not allocate hostname")
@@ -63,6 +64,58 @@ class DdnsService:
raise raise
self.store.delete_account(hostname) 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( def apply_update(
self, self,
request: Request, request: Request,
@@ -145,24 +198,18 @@ class DdnsService:
def fritz_update_url(self, account: dict[str, str]) -> str: def fritz_update_url(self, account: dict[str, str]) -> str:
base = self.settings.public_base_url.rstrip("/") base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["update_slug"]) 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: def dashboard_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:
base = self.settings.public_base_url.rstrip("/") base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"]) slug = urllib.parse.quote(account["management_slug"])
return f"{base}/m/{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: def random_managed_hostname(self) -> str:
suffix = self.settings.hostname_suffix.strip().lower().strip(".") suffix = self.settings.hostname_suffix.strip().lower().strip(".")
if not suffix: if not suffix:
@@ -213,6 +260,15 @@ def parse_update_ips(
return ipv4, ipv6, update_ipv4, update_ipv6 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: def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) -> str | None:
selected = None selected = None
for name, value in params: for name, value in params:
@@ -2,7 +2,6 @@ from __future__ import annotations
import re import re
import urllib.parse import urllib.parse
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -30,6 +29,7 @@ class DdnsSettings(BaseModel):
require_dns_provider: bool = Field(default=False) require_dns_provider: bool = Field(default=False)
cloudflare_api_token: str = Field(default="") cloudflare_api_token: str = Field(default="")
cloudflare_zone_id: 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_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_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) 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): if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path):
raise ValueError("admin_path may only contain URL-safe path characters") raise ValueError("admin_path may only contain URL-safe path characters")
reserved_paths = { reserved_paths = {
"/accounts",
"/admin.css", "/admin.css",
"/admin-tables.js", "/admin-tables.js",
"/api", "/api",
@@ -70,14 +69,11 @@ class DdnsSettings(BaseModel):
"/favicon.ico", "/favicon.ico",
"/healthz", "/healthz",
"/install.sh", "/install.sh",
"/ip",
"/bootstrap-subset.css", "/bootstrap-subset.css",
"/logo.png", "/logo.png",
"/logo-nav.webp", "/logo-nav.webp",
"/logo.webp", "/logo.webp",
"/m", "/m",
"/magic",
"/nic",
"/openapi.json", "/openapi.json",
"/privacy", "/privacy",
"/redoc", "/redoc",
@@ -87,7 +83,6 @@ class DdnsSettings(BaseModel):
"/social-preview.png", "/social-preview.png",
"/social-preview.webp", "/social-preview.webp",
"/theme.js", "/theme.js",
"/u",
"/uninstall.sh", "/uninstall.sh",
} }
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths): 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 from .config import settings_from_env
return 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 from __future__ import annotations
import re
import secrets import secrets
from datetime import UTC, datetime 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 from .types import AccountRow
class AccountsMixin: 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() now = datetime.now(UTC).isoformat()
token = secrets.token_urlsafe(24)
update_slug = secrets.token_urlsafe(32) update_slug = secrets.token_urlsafe(32)
management_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) management_hash = hash_lookup_token(management_slug)
accepted_at = now if accepted else None
with self.connect() as conn: with self.connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled) INSERT INTO accounts(hostname, update_slug, management_hash, created_at, accepted_at, disabled)
VALUES (?, ?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, 0)
""", """,
(hostname, username, token_hash, update_slug, management_hash, now), (hostname, update_slug, management_hash, now, accepted_at),
) )
return { return {
"hostname": hostname, "hostname": hostname,
"username": username,
"password": token,
"update_slug": update_slug, "update_slug": update_slug,
"management_slug": management_slug, "management_slug": management_slug,
"accepted_at": accepted_at,
} }
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]: def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
with self.connect() as conn: with self.connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT accounts.hostname, accounts.username, accounts.update_slug, SELECT accounts.hostname, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname ORDER BY accounts.hostname
@@ -57,8 +53,8 @@ class AccountsMixin:
with self.connect() as conn: with self.connect() as conn:
account = conn.execute( account = conn.execute(
""" """
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash, SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ? WHERE accounts.hostname = ?
@@ -71,7 +67,7 @@ class AccountsMixin:
with self.connect() as conn: with self.connect() as conn:
account = conn.execute( 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 = ? WHERE update_slug = ?
""", """,
(update_slug,), (update_slug,),
@@ -82,8 +78,8 @@ class AccountsMixin:
with self.connect() as conn: with self.connect() as conn:
account = conn.execute( account = conn.execute(
""" """
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash, SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ? WHERE accounts.management_hash = ?
@@ -92,20 +88,14 @@ class AccountsMixin:
).fetchone() ).fetchone()
return dict(account) if account else None return dict(account) if account else None
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool: def activate_account(self, hostname: str) -> AccountRow | None:
if not username or not token: now = datetime.now(UTC).isoformat()
return False
with self.connect() as conn: with self.connect() as conn:
account = conn.execute( conn.execute(
""" "UPDATE accounts SET accepted_at = COALESCE(accepted_at, ?) WHERE hostname = ?",
SELECT token_hash, disabled FROM accounts (now, hostname),
WHERE hostname = ? AND username = ? )
""", return self.get_account_by_hostname(hostname)
(hostname, username),
).fetchone()
if not account or account["disabled"]:
return False
return verify_secret(token, account["token_hash"])
def delete_account(self, hostname: str) -> None: def delete_account(self, hostname: str) -> None:
with self.connect() as conn: with self.connect() as conn:
@@ -128,16 +118,6 @@ class AccountsMixin:
account["management_slug"] = management_slug account["management_slug"] = management_slug
return account 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: def set_dashboard_password(self, hostname: str, password: str) -> AccountRow | None:
password_hash = hash_secret(password) password_hash = hash_secret(password)
with self.connect() as conn: with self.connect() as conn:
@@ -166,8 +146,3 @@ class AccountsMixin:
if not account or account["disabled"] or not account["dashboard_password_hash"]: if not account or account["disabled"] or not account["dashboard_password_hash"]:
return False return False
return verify_secret(password, account["dashboard_password_hash"]) 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, self,
unused_account_hours: int, unused_account_hours: int,
event_history_hours: int = 24 * 180, event_history_hours: int = 24 * 180,
pending_account_hours: int = 24,
) -> dict[str, int]: ) -> dict[str, int]:
self.flush_rate_limits() self.flush_rate_limits()
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat() 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() event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
with self.connect() as conn: with self.connect() as conn:
account_cursor = conn.execute( account_cursor = conn.execute(
""" """
DELETE FROM accounts DELETE FROM accounts
WHERE created_at < ? WHERE (
AND NOT EXISTS ( accepted_at IS NULL
SELECT 1 FROM records WHERE records.hostname = accounts.hostname 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( rollup_cursor = conn.execute(
"DELETE FROM update_event_rollups WHERE last_seen_at < ?", "DELETE FROM update_event_rollups WHERE last_seen_at < ?",
@@ -3,7 +3,7 @@ from __future__ import annotations
import sqlite3 import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from ..ddns_models import UpdateResult from ..models import UpdateResult
from .types import UpdateEventRow from .types import UpdateEventRow
File renamed without changes.
@@ -4,13 +4,13 @@ import secrets
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
from ..ddns_security import hash_lookup_token from ..security import hash_lookup_token
BUSY_TIMEOUT_MS = 10_000 BUSY_TIMEOUT_MS = 10_000
CACHE_SIZE_KIB = 20_000 CACHE_SIZE_KIB = 20_000
MMAP_SIZE_BYTES = 256 * 1024 * 1024 MMAP_SIZE_BYTES = 256 * 1024 * 1024
WAL_AUTOCHECKPOINT_PAGES = 1_000 WAL_AUTOCHECKPOINT_PAGES = 1_000
SCHEMA_VERSION = 1 SCHEMA_VERSION = 3
def connect_database(database_path: Path) -> sqlite3.Connection: 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: def initialize_schema(conn: sqlite3.Connection) -> None:
configure_connection(conn) configure_connection(conn)
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA journal_mode = WAL")
conn.execute( conn.execute(
""" """
@@ -143,12 +144,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
""" """
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY, hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
update_slug TEXT NOT NULL UNIQUE, update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE, management_hash TEXT UNIQUE,
dashboard_password_hash TEXT, dashboard_password_hash TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0 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", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT") _ensure_column(conn, "accounts", "management_hash", "TEXT")
_ensure_column(conn, "accounts", "dashboard_password_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 = ''"): 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])) 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 = ''"): 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 = ?", "UPDATE accounts SET management_hash = ? WHERE hostname = ?",
(hash_lookup_token(secrets.token_urlsafe(32)), row[0]), (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_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 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_created_at ON accounts(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_hostname_username ON accounts(hostname, username)") if schema_version < SCHEMA_VERSION:
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.execute("PRAGMA optimize") 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}") 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: def _migrate_cleanup_runs_schema(conn: sqlite3.Connection) -> None:
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")} columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
if columns <= {"id", "created_at", "unused_accounts_deleted"}: if columns <= {"id", "created_at", "unused_accounts_deleted"}:
@@ -5,17 +5,15 @@ from typing import TypedDict
class AccountRow(TypedDict, total=False): class AccountRow(TypedDict, total=False):
hostname: str hostname: str
username: str
token_hash: str
update_slug: str update_slug: str
management_slug: str management_slug: str
dashboard_password_hash: str | None dashboard_password_hash: str | None
created_at: str created_at: str
accepted_at: str | None
disabled: int disabled: int
ipv4: str | None ipv4: str | None
ipv6: str | None ipv6: str | None
updated_at: str | None updated_at: str | None
password: str
class UpdateEventRow(TypedDict, total=False): 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 fastapi import HTTPException, Request
from ..ddns_models import DdnsSettings from ..settings import DdnsSettings
MAX_FORM_FIELDS = 100 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 __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 = """ THEME_JS = """
(() => { (() => {
const media = window.matchMedia("(prefers-color-scheme: dark)"); const media = window.matchMedia("(prefers-color-scheme: dark)");
@@ -201,123 +190,3 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
render(); render();
}); });
""".strip() """.strip()
def _minify_css(css: str) -> str:
return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}")
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
@router.get("/favicon.ico")
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.concurrency import run_in_threadpool
from starlette.types import ASGIApp, Message, Receive, Scope, Send from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..ddns_models import DdnsSettings from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket from ..settings import DdnsSettings
from ..ddns_store import DdnsStore from ..store import DdnsStore
ACCESS_LOGGER = logging.getLogger("router_dyndns.access") ACCESS_LOGGER = logging.getLogger("karldns.access")
class RequestBodyTooLargeError(Exception): 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: def is_admin_path(path: str, settings: DdnsSettings) -> bool:
admin_path = settings.admin_path.rstrip("/") admin_path = settings.admin_path.rstrip("/")
admin_records = f"{admin_path}/records" return path == admin_path or path.startswith(f"{admin_path}/") or path.startswith("/api/v1/admin/")
admin_events = f"{admin_path}/events"
legacy_admin_api = settings.admin_path == "/admin" and path in {"/records", "/events"}
return path == admin_path or path.startswith(f"{admin_path}/") or path in {admin_records, admin_events} or legacy_admin_api
def is_secret_response(request: Request) -> bool: def is_secret_response(request: Request) -> bool:
path = request.url.path path = request.url.path
settings = request.app.state.settings 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 return True
if path.startswith("/api/v1/hostnames/"): if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
return request.method in {"POST", "DELETE"} return True
return (request.method == "POST" and path in {"/magic", "/accounts"}) or path == "/nic/update" if path.startswith("/api/v1/admin/"):
return True
return request.method == "POST" and path == "/"
def is_noindex_response(request: Request) -> bool: def is_noindex_response(request: Request) -> bool:
path = request.url.path path = request.url.path
if request.method != "GET": if request.method != "GET":
return True return True
if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}: if path in {"/openapi.json", "/healthz"}:
return True return True
if path.startswith(("/api/", "/m/", "/u/", "/nic/")): if path.startswith(("/api/", "/m/")):
return True return True
return is_admin_path(path, request.app.state.settings) return is_admin_path(path, request.app.state.settings)
def content_security_policy(path: str) -> str: def content_security_policy(path: str) -> str:
if path in {"/docs", "/redoc", "/openapi.json"}:
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'; " script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
return ( return (
"default-src 'self'; " "default-src 'self'; "
@@ -152,14 +140,12 @@ def content_security_policy(path: str) -> str:
def redacted_access_path(path: str) -> str: def redacted_access_path(path: str) -> str:
secret_prefixes = { secret_prefixes = {
"/u": "/u/<secret>",
"/m": "/m/<secret>", "/m": "/m/<secret>",
"/api/v1/updates": "/api/v1/updates/<secret>", "/api/v1/hostnames": "/api/v1/hostnames/<secret>",
"/api/v1/management": "/api/v1/management/<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(): for prefix, replacement in secret_prefixes.items():
if path == prefix or path.startswith(f"{prefix}/"): if path == prefix or path.startswith(f"{prefix}/"):
return replacement return replacement
if path == "/nic/update":
return "/nic/update?<redacted>"
return path return path
@@ -163,11 +163,41 @@ code {
.theme-toggle { .theme-toggle {
align-items: center; align-items: center;
aspect-ratio: 1; appearance: none;
background: transparent;
border: 0;
border-radius: 999px;
color: var(--rp-muted);
cursor: pointer;
display: inline-flex; display: inline-flex;
flex: 0 0 auto;
height: 30px;
justify-content: center; justify-content: center;
min-height: 40px; padding: 0;
min-width: 40px; 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; padding: 0;
} }
@@ -224,6 +254,10 @@ code {
background: var(--rp-canvas); background: var(--rp-canvas);
} }
.section-compact {
padding: 56px 0;
}
.section + .section { .section + .section {
border-top: 0; border-top: 0;
} }
@@ -327,6 +361,94 @@ h3 {
font: 400 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 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 { .card {
background: var(--rp-surface); background: var(--rp-surface);
border: 1px solid var(--rp-line); border: 1px solid var(--rp-line);
@@ -582,6 +704,7 @@ select:focus-visible {
color: var(--rp-terminal-ink); color: var(--rp-terminal-ink);
background: var(--rp-terminal); background: var(--rp-terminal);
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
text-align: left;
} }
.code-window { .code-window {
@@ -752,6 +875,10 @@ select:focus-visible {
padding: 64px 0; padding: 64px 0;
} }
.section-compact {
padding: 48px 0;
}
.hero-band { .hero-band {
min-height: auto; 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) { @media (max-width: 560px) {
.container, .container,
.container.narrow, .container.narrow,
@@ -797,10 +935,26 @@ select:focus-visible {
} }
.nav-label { .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-band .btn,
.hero-action-form,
.card button, .card button,
.danger-section form, .danger-section form,
.danger-section button { .danger-section button {
@@ -835,6 +989,13 @@ select:focus-visible {
border-radius: 0; border-radius: 0;
} }
.copy-field code.form-control {
flex: 1 1 auto;
width: 100%;
overflow: visible;
text-overflow: clip;
}
.copy-field .btn { .copy-field .btn {
width: 100%; width: 100%;
border-radius: 0; border-radius: 0;
@@ -2,11 +2,11 @@ from __future__ import annotations
from urllib.parse import urlencode from urllib.parse import urlencode
from ..ddns_models import DdnsSettings from ..security import admin_csrf_token
from ..ddns_security import admin_csrf_token from ..services.ddns import DdnsService
from ..ddns_service import DdnsService from ..settings import DdnsSettings
from .html_utils import SafeHtml, attr, escape, trusted 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 ( from .templates_admin_tables import (
_account_row, _account_row,
_admin_table_pagination, _admin_table_pagination,
@@ -45,8 +45,6 @@ def _render_admin_page(
csrf = attr(admin_csrf_token(settings)) csrf = attr(admin_csrf_token(settings))
admin_path = settings.admin_path admin_path = settings.admin_path
safe_admin_path = attr(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 """ 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> <tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
""" """
@@ -63,7 +61,7 @@ def _render_admin_page(
"KarlDNS Admin", "KarlDNS Admin",
trusted(f""" trusted(f"""
<main id="main"> <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"> <section class="section admin-heading">
<div class="container"> <div class="container">
<p class="eyebrow">KarlDNS</p> <p class="eyebrow">KarlDNS</p>
@@ -102,7 +100,7 @@ def _render_admin_page(
<div class="table-responsive border rounded admin-table-scroll"> <div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0"> <table class="table mb-0">
<thead> <thead>
<tr><th 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> </thead>
<tbody>{rows}</tbody> <tbody>{rows}</tbody>
</table> </table>
@@ -1,29 +1,29 @@
from __future__ import annotations from __future__ import annotations
from .components import _event_label
from .html_utils import attr, escape from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str: def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
hostname = escape(account["hostname"]) hostname = escape(account["hostname"])
username = escape(account["username"]) status = "Active" if account.get("accepted_at") else "Pending"
ipv4 = escape(account.get("ipv4") or "-") ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-") ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never") updated = escape(account.get("updated_at") or "Never")
safe_admin_path = attr(admin_path) safe_admin_path = attr(admin_path)
return f""" return f"""
<tr> <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> <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="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}"> <input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button> <button name="action" value="update" class="btn btn-sm btn-light px-3" title="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button> <button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Create a new router password" aria-label="New password for {hostname}">New password</button>
</form> </form>
</td> </td>
<td> <td>
<form method="post" action="{safe_admin_path}/accounts/delete"> <form method="post" action="{safe_admin_path}/hostnames/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}"> <input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}"> <input type="hidden" name="hostname" value="{hostname}">
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button> <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: def _event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-") created = escape(event.get("created_at") or "-")
hostname = escape(event.get("hostname") 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 "-") ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-") ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1) count = escape(event.get("count") or 1)
@@ -1,20 +1,19 @@
from __future__ import annotations from __future__ import annotations
from ..ddns_models import DdnsSettings from ..services.ddns import DdnsService
from ..ddns_service import DdnsService from ..settings import DdnsSettings
from .html_utils import SafeHtml, attr, escape, trusted from .components import (
from .templates import (
_copy_menu, _copy_menu,
_copy_row, _copy_row,
_event_label,
_fritzbox_fields, _fritzbox_fields,
_manual_update_commands, _manual_update_commands,
_manual_update_panel, _manual_update_panel,
_one_time_secret_notice,
_page,
_secret_notice, _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( def _render_management_page(
@@ -25,28 +24,26 @@ def _render_management_page(
csrf_token: str, csrf_token: str,
message: str | None = None, message: str | None = None,
created_account: dict[str, str] | None = None, created_account: dict[str, str] | None = None,
request_ip: str | None = None,
) -> str: ) -> str:
account_for_url = { account_for_url = {
"hostname": str(account["hostname"]), "hostname": str(account["hostname"]),
"username": str(account["username"]),
"password": "",
"update_slug": str(account["update_slug"]), "update_slug": str(account["update_slug"]),
"management_slug": management_slug, "management_slug": management_slug,
} }
update_url = service.fritz_update_url(account_for_url) update_url = service.fritz_update_url(account_for_url)
hostname = escape(account["hostname"]) hostname = escape(account["hostname"])
username = escape(account["username"])
ipv4 = escape(account.get("ipv4") or "-") ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-") ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never") 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}." 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 """ 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> <tr><td colspan="6" class="empty">No router updates yet.</td></tr>
""" """
message_html = _management_message(message) 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"))) dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
return _page( return _page(
"KarlDNS management", "KarlDNS management",
@@ -62,15 +59,14 @@ def _render_management_page(
</div> </div>
</section> </section>
{message_html} {message_html}
{rotated_credentials} {rotated_links}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="row g-4 align-items-start"> <div class="row g-4 align-items-start">
<div class="col-lg-6"> <div class="col-lg-6">
<p class="eyebrow">Router settings</p> <p class="eyebrow">Router settings</p>
<h2>Router update settings</h2> <h2>Router update settings</h2>
<p class="section-copy">Use the Update-URL 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> <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>
{_one_time_secret_notice()}
{_secret_notice()} {_secret_notice()}
<div class="copy-actions mt-3"> <div class="copy-actions mt-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])} {_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("Update-URL:", update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")} {_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")} {_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
</div> </div>
</div> </div>
{_manual_update_panel(manual_commands)} {_manual_update_panel(manual_commands)}
<div class="card card-body gap-3 mt-4"> <div class="card card-body gap-3 mt-4">
<div> <div>
<p class="eyebrow">Actions</p> <p class="eyebrow">Actions</p>
<h3>Replace router secrets</h3> <h3>Replace router update URL</h3>
<p class="section-copy">Create a new update URL or router password. Update your router after changing either one.</p> <p class="section-copy">Create a new update URL. Update your router after changing it.</p>
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<form method="post" action="/m/{attr(management_slug)}/rotate"> <form method="post" action="/m/{attr(management_slug)}/rotate">
@@ -97,11 +91,6 @@ def _render_management_page(
<input type="hidden" name="action" value="update"> <input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button> <button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
</form> </form>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<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> </div>
</div> </div>
@@ -159,6 +148,44 @@ def _render_management_page(
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'), 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: def _management_message(message: str | None) -> SafeHtml:
if not message: if not message:
return trusted("") return trusted("")
@@ -273,7 +300,7 @@ def _render_management_unlock_page(
def _user_event_row(event: dict[str, str | int | None]) -> str: def _user_event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-") 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 "-") ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-") ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1) 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] [project.scripts]
karldns = "router_dyndns.cli:main" karldns = "karldns.cli:main"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["router_dyndns"] packages = ["karldns"]
artifacts = ["router_dyndns/assets/*"] artifacts = ["karldns/assets/**/*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] 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 from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] 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 = [ SCAN_PATHS = [
ROOT / "router_dyndns" / "web", ROOT / "karldns" / "web",
ROOT / "router_dyndns" / "routers", ROOT / "karldns" / "routers",
] ]
CSS = r""" 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 :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"} SUPPORTED_DYNAMIC_CLASSES = {"active", "disabled", "page-item", "page-link"}
@@ -44,14 +47,18 @@ def parse_args() -> argparse.Namespace:
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
output = args.output if args.output.is_absolute() else ROOT / args.output 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 supported = _class_names_from_css(CSS) | _class_names_from_css(app_styles) | SUPPORTED_DYNAMIC_CLASSES
unsupported = sorted(_class_names_from_templates() - supported) unsupported = sorted(_class_names_from_templates() - supported)
if unsupported: if unsupported:
unsupported_text = "\n".join(f"- {class_name}" for class_name in 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}") raise SystemExit(f"Unsupported Bootstrap-style classes in templates:\n{unsupported_text}")
output.write_text(CSS + "\n") 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 return 0
+71 -9
View File
@@ -7,11 +7,13 @@ KARLDNS_INSTALL_MODE="${KARLDNS_INSTALL_MODE:-auto}"
KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL:-}" KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL:-}"
KARLDNS_HOSTNAME="${KARLDNS_HOSTNAME:-}" KARLDNS_HOSTNAME="${KARLDNS_HOSTNAME:-}"
KARLDNS_MANAGEMENT_URL="${KARLDNS_MANAGEMENT_URL:-}" 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_RAW_BASE_URL="${KARLDNS_RAW_BASE_URL:-https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts}"
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}" KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}" KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
KARLDNS_SKIP_CRON="${KARLDNS_SKIP_CRON:-0}" KARLDNS_SKIP_CRON="${KARLDNS_SKIP_CRON:-0}"
KARLDNS_SKIP_FIRST_UPDATE="${KARLDNS_SKIP_FIRST_UPDATE:-0}" KARLDNS_SKIP_FIRST_UPDATE="${KARLDNS_SKIP_FIRST_UPDATE:-0}"
KARLDNS_REUSED_CONFIG=0
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -28,6 +30,7 @@ Options:
--update-url URL Existing private update URL --update-url URL Existing private update URL
--hostname NAME Hostname to show after install --hostname NAME Hostname to show after install
--dashboard-url URL Private dashboard URL 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 --ipv4 VALUE auto, off, or a static IPv4. Default: auto
--ipv6 VALUE auto, off, or a static IPv6. Default: auto --ipv6 VALUE auto, off, or a static IPv6. Default: auto
--no-cron Install files only, do not configure cron --no-cron Install files only, do not configure cron
@@ -49,6 +52,7 @@ while [ "$#" -gt 0 ]; do
--update-url) KARLDNS_UPDATE_URL="${2:?missing value for --update-url}"; shift 2 ;; --update-url) KARLDNS_UPDATE_URL="${2:?missing value for --update-url}"; shift 2 ;;
--hostname) KARLDNS_HOSTNAME="${2:?missing value for --hostname}"; shift 2 ;; --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 ;; --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 ;; --ipv4) KARLDNS_IPV4="${2:?missing value for --ipv4}"; shift 2 ;;
--ipv6) KARLDNS_IPV6="${2:?missing value for --ipv6}"; shift 2 ;; --ipv6) KARLDNS_IPV6="${2:?missing value for --ipv6}"; shift 2 ;;
--no-cron) KARLDNS_SKIP_CRON=1; shift ;; --no-cron) KARLDNS_SKIP_CRON=1; shift ;;
@@ -81,6 +85,7 @@ quote_sh() {
download_updater() { download_updater() {
target_path="$1" target_path="$1"
tmp_path="$(mktemp)" tmp_path="$(mktemp)"
mkdir -p "$(dirname "$target_path")"
if ! "$curl_bin" -fsS "$KARLDNS_RAW_BASE_URL/karldns-update.sh" -o "$tmp_path"; then if ! "$curl_bin" -fsS "$KARLDNS_RAW_BASE_URL/karldns-update.sh" -o "$tmp_path"; then
rm -f "$tmp_path" rm -f "$tmp_path"
exit 1 exit 1
@@ -89,6 +94,18 @@ download_updater() {
mv "$tmp_path" "$target_path" 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() { cron_schedule() {
if [ "$KARLDNS_INTERVAL_MINUTES" = "60" ]; then if [ "$KARLDNS_INTERVAL_MINUTES" = "60" ]; then
printf '0 * * * *' printf '0 * * * *'
@@ -113,6 +130,44 @@ update_crontab() {
printf '%s\n' "$marker_end" 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() { write_config() {
config_file="$1" config_file="$1"
cache_file="$2" cache_file="$2"
@@ -123,6 +178,7 @@ write_config() {
printf 'KARLDNS_UPDATE_URL=%s\n' "$(quote_sh "$KARLDNS_UPDATE_URL")" printf 'KARLDNS_UPDATE_URL=%s\n' "$(quote_sh "$KARLDNS_UPDATE_URL")"
printf 'KARLDNS_HOSTNAME=%s\n' "$(quote_sh "$KARLDNS_HOSTNAME")" printf 'KARLDNS_HOSTNAME=%s\n' "$(quote_sh "$KARLDNS_HOSTNAME")"
printf 'KARLDNS_MANAGEMENT_URL=%s\n' "$(quote_sh "$KARLDNS_MANAGEMENT_URL")" 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_CACHE_FILE=%s\n' "$(quote_sh "$cache_file")"
printf 'KARLDNS_LOCK_FILE=%s\n' "$(quote_sh "$lock_file")" printf 'KARLDNS_LOCK_FILE=%s\n' "$(quote_sh "$lock_file")"
printf 'KARLDNS_IPV4=%s\n' "$(quote_sh "$KARLDNS_IPV4")" printf 'KARLDNS_IPV4=%s\n' "$(quote_sh "$KARLDNS_IPV4")"
@@ -161,7 +217,6 @@ install_system() {
cache_dir="/var/cache/karldns" cache_dir="/var/cache/karldns"
config_file="${config_dir}/updater.env" config_file="${config_dir}/updater.env"
updater_path="/usr/local/bin/karldns-update" updater_path="/usr/local/bin/karldns-update"
cron_file="/etc/cron.d/karldns-updater"
mkdir -p "$config_dir" "$cache_dir" mkdir -p "$config_dir" "$cache_dir"
download_updater "$updater_path" download_updater "$updater_path"
@@ -169,12 +224,7 @@ install_system() {
write_config "$config_file" "$cache_dir/last-ip" "/run/karldns-updater.lock" write_config "$config_file" "$cache_dir/last-ip" "/run/karldns-updater.lock"
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
cat > "$cron_file" <<EOF install_system_cron "$(cron_schedule)" "KARLDNS_CONFIG=$(quote_sh "$config_file") $(quote_sh "$updater_path") >/dev/null 2>&1"
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"
fi fi
} }
@@ -206,12 +256,19 @@ if [ "$KARLDNS_INSTALL_MODE" = "system" ] && [ "$(id -u)" -ne 0 ]; then
exit 1 exit 1
fi 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 if [ -z "$KARLDNS_UPDATE_URL" ]; then
echo "KarlDNS: creating a new hostname at $KARLDNS_BASE_URL ..." 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_UPDATE_URL="$(printf '%s' "$response" | json_field update_url)"
KARLDNS_HOSTNAME="$(printf '%s' "$response" | json_field hostname)" 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 fi
if [ -z "$KARLDNS_UPDATE_URL" ]; then if [ -z "$KARLDNS_UPDATE_URL" ]; then
@@ -222,6 +279,11 @@ case "$KARLDNS_UPDATE_URL" in
*"<ipaddr>"*|*"<ip6addr>"*) KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL%%\?*}" ;; *"<ipaddr>"*|*"<ip6addr>"*) KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL%%\?*}" ;;
esac 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 if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
install_system install_system
else 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_LOCK_FILE="${KARLDNS_LOCK_FILE:-/tmp/karldns-updater.lock}"
KARLDNS_CACHE_FILE="${KARLDNS_CACHE_FILE:-${XDG_CACHE_HOME:-$HOME/.cache}/karldns/last-ip}" KARLDNS_CACHE_FILE="${KARLDNS_CACHE_FILE:-${XDG_CACHE_HOME:-$HOME/.cache}/karldns/last-ip}"
KARLDNS_CURL="${KARLDNS_CURL:-curl}" KARLDNS_CURL="${KARLDNS_CURL:-curl}"
KARLDNS_IP_URL="${KARLDNS_IP_URL:-$KARLDNS_BASE_URL/ip}"
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}" KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}" KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
case "$KARLDNS_UPDATE_URL" in case "$KARLDNS_UPDATE_URL" in
@@ -39,12 +38,12 @@ fi
ipv4="" ipv4=""
ipv6="" ipv6=""
if [ "$KARLDNS_IPV4" = "auto" ]; then 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 elif [ "$KARLDNS_IPV4" != "off" ]; then
ipv4="$KARLDNS_IPV4" ipv4="$KARLDNS_IPV4"
fi fi
if [ "$KARLDNS_IPV6" = "auto" ]; then 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 elif [ "$KARLDNS_IPV6" != "off" ]; then
ipv6="$KARLDNS_IPV6" ipv6="$KARLDNS_IPV6"
fi fi
+29
View File
@@ -48,6 +48,33 @@ remove_user() {
fi 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() { remove_system() {
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "System uninstall needs root. Re-run with sudo or set --mode user." >&2 echo "System uninstall needs root. Re-run with sudo or set --mode user." >&2
@@ -55,6 +82,8 @@ remove_system() {
fi fi
rm -f /etc/cron.d/karldns-updater /usr/local/bin/karldns-update 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 if [ "$KARLDNS_DELETE_CONFIG" = "1" ]; then
rm -rf /etc/karldns /var/cache/karldns rm -rf /etc/karldns /var/cache/karldns
fi fi
+53 -27
View File
@@ -2,8 +2,8 @@ from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app from karldns import DdnsSettings, DdnsStore, make_app
from router_dyndns.ddns_security import admin_csrf_token from karldns.security import admin_csrf_token
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None: 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")) response = client.get("/ops", auth=("admin", "admin"))
assert response.status_code == 200 assert response.status_code == 200
assert 'href="/ops/records"' in response.text assert 'href="/ops/records"' not in response.text
assert 'href="/ops/events"' in response.text assert 'href="/ops/events"' not in response.text
assert 'action="/ops/cleanup"' 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 '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("/records", auth=("admin", "admin")).status_code == 404
assert client.get("/admin", 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")) app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app) client = TestClient(app)
records = client.get("/records?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("/events?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["limit"] == 1
assert records["offset"] == 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 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: 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")) app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app) client = TestClient(app)
records = client.get("/records?offset=10001", auth=("admin", "admin")) records = client.get("/api/v1/admin/records?offset=10001", auth=("admin", "admin"))
events = client.get("/events?offset=10001", auth=("admin", "admin")) events = client.get("/api/v1/admin/events?offset=10001", auth=("admin", "admin"))
assert records.status_code == 422 assert records.status_code == 422
assert events.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")) app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app) client = TestClient(app)
first = client.get("/events?limit=1", auth=("admin", "admin")).json() first = client.get("/api/v1/admin/events?limit=1", auth=("admin", "admin")).json()
second = client.get(f"/events?limit=1&before={first['next_before']}", 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 first["items"][0]["hostname"] == "raw-new.example.net"
assert second["items"][0]["hostname"] == "raw-old.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) csrf = admin_csrf_token(settings)
update = client.post( update = client.post(
"/admin/accounts/rotate", "/admin/hostnames/rotate",
auth=("admin", "admin"), auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"}, data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
) )
assert update.status_code == 200 assert update.status_code == 200
assert "Update URL changed" in update.text assert "Update URL changed" in update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404 assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
management = client.post( management = client.post(
"/admin/accounts/rotate", "/admin/hostnames/rotate",
auth=("admin", "admin"), auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"}, 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 "Dashboard link changed" in management.text
assert "/m/" 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)} 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_update_url_rotated" in statuses
assert "admin_management_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: 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) client = TestClient(app)
response = client.post( response = client.post(
"/admin/accounts/delete", "/admin/hostnames/delete",
auth=("admin", "admin"), auth=("admin", "admin"),
data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "}, data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "},
follow_redirects=False, follow_redirects=False,
+225 -124
View File
@@ -1,81 +1,95 @@
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import urlparse
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service import karldns.services.ddns as ddns_module
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app from karldns import DdnsSettings, DdnsStore, DnsProviderError, make_app
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None: def _path(url: str) -> str:
database_path = tmp_path / "ddns.sqlite3" parsed = urlparse(url)
return parsed.path
def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> None:
app = make_app( app = make_app(
DdnsSettings( DdnsSettings(
database_path=database_path, database_path=tmp_path / "ddns.sqlite3",
admin_password="admin", admin_password="admin",
public_base_url="http://ddns.example.net", public_base_url="https://karldns.de",
hostname_suffix="ddns.example.net",
) )
) )
client = TestClient(app) 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") docs = client.get("/docs")
redoc = client.get("/redoc") redoc = client.get("/redoc")
schema = client.get("/openapi.json").json() schema = client.get("/openapi.json").json()
assert docs.status_code == 200 assert docs.status_code == 200
assert redoc.status_code == 200 assert redoc.status_code == 404
assert docs.headers["x-robots-tag"] == "noindex, nofollow" assert "Small API surface." in docs.text
assert redoc.headers["x-robots-tag"] == "noindex, nofollow" 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 client.get("/openapi.json").headers["x-robots-tag"] == "noindex, nofollow"
assert "unsafe-inline" in docs.headers["content-security-policy"] assert "cdn.jsdelivr.net" not in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"] assert "fonts.googleapis.com" not in docs.headers["content-security-policy"]
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
assert "/api/v1/hostnames/magic" in schema["paths"] expected_paths = {
assert "/api/v1/updates/{update_slug}" in schema["paths"] "/api/v1/admin/dashboard",
assert "/u/{update_slug}" in schema["paths"] "/api/v1/admin/events",
assert "/nic/update" in schema["paths"] "/api/v1/admin/hostnames",
assert "/ip" in schema["paths"] "/api/v1/admin/records",
assert "/healthz" in schema["paths"] "/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 "/admin" not in schema["paths"]
assert "/healthz" not in schema["paths"]
assert "/records" not in schema["paths"] assert "/records" not in schema["paths"]
assert "/events" 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 any(tag["name"] == "hostnames" for tag in schema["tags"])
assert all(tag["name"] != "admin" for tag in schema["tags"]) assert any(tag["name"] == "router" for tag in schema["tags"])
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management") assert any(tag["name"] == "admin" for tag in schema["tags"])
assert "Dashboard passwords protect only the web dashboard" in management_tag["description"]
management_get = schema["paths"]["/api/v1/management/{management_slug}"]["get"]
management_delete = schema["paths"]["/api/v1/management/{management_slug}"]["delete"]
assert "Dashboard passwords protect only the web dashboard" in management_get["description"]
assert "Dashboard passwords protect only the web dashboard" in management_delete["description"]
def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None: 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( app = make_app(
DdnsSettings( DdnsSettings(
database_path=tmp_path / "ddns.sqlite3", 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) client = TestClient(app)
redoc = client.get("/redoc")
schema = client.get("/openapi.json").json() schema = client.get("/openapi.json").json()
forbidden_paths = { forbidden_paths = {
"/ops", "/ops",
"/ops/accounts/delete", "/ops/hostnames/delete",
"/ops/accounts/rotate", "/ops/hostnames/rotate",
"/ops/cleanup", "/ops/cleanup",
"/ops/events", "/ops/events",
"/ops/records", "/ops/records",
"/records", "/records",
"/events", "/events",
} }
assert redoc.status_code == 200 assert client.get("/redoc").status_code == 404
assert forbidden_paths.isdisjoint(schema["paths"]) 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")) app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app) client = TestClient(app)
schema = client.get("/openapi.json").json() schema = client.get("/openapi.json").json()
created = schema["components"]["schemas"]["HostnameCreateResponse"]["properties"]
status = schema["components"]["schemas"]["HostnameStatusResponse"]["properties"]
expected_public_paths = { for name in ["update_url", "dashboard_url", "registration_url"]:
"/api/v1/hostnames/magic", assert "Secret" in created[name]["description"]
"/api/v1/management/{management_slug}", for name in ["update_url", "dashboard_url"]:
"/api/v1/updates/{update_slug}", assert "Secret" in status[name]["description"]
"/healthz",
"/ip",
"/nic/update",
"/u/{update_slug}",
}
assert set(schema["paths"]) == expected_public_paths
def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None: def test_hostname_api_lifecycle_and_history(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:
app = make_app( app = make_app(
DdnsSettings( DdnsSettings(
database_path=tmp_path / "ddns.sqlite3", database_path=tmp_path / "ddns.sqlite3",
@@ -167,38 +141,165 @@ def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
) )
client = TestClient(app) 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.status_code == 201
assert created.headers["cache-control"] == "no-store" assert created.headers["cache-control"] == "no-store"
assert created.headers["x-robots-tag"] == "noindex, nofollow"
body = created.json() body = created.json()
assert body["hostname"].endswith(".ddns.example.net") assert body["hostname"].endswith(".ddns.example.net")
assert body["update_url"].startswith("http://ddns.example.net/u/") assert body["status"] == "pending"
assert body["management_url"].startswith("http://ddns.example.net/m/") 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] dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
managed = client.get(f"/api/v1/management/{management_slug}") update_path = _path(body["update_url"])
assert managed.status_code == 200 register_path = _path(body["registration_url"])
assert managed.json()["hostname"] == body["hostname"]
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 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: def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None:
published = [] 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" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router") account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app) 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.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 len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.11" 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): def fail_publish(result, settings):
raise DnsProviderError("dns down") 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" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router") account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app) 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 response.status_code == 500
assert store.list_records() == [] assert store.list_records() == []
@@ -232,8 +333,8 @@ def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
) )
client = TestClient(app) client = TestClient(app)
first = client.post("/api/v1/hostnames/magic", json={"username": "router"}) first = client.post("/api/v1/hostnames")
second = client.post("/api/v1/hostnames/magic", json={"username": "router"}) second = client.post("/api/v1/hostnames")
assert first.status_code == 201 assert first.status_code == 201
assert second.status_code == 429 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")) app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app) 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.status_code == 400
assert response.json()["detail"] == "myipv6 must be an IPv6 address" assert response.text == "badip"
assert store.list_records() == [] assert store.list_records() == []
+108 -8
View File
@@ -3,27 +3,29 @@ import logging
import sqlite3 import sqlite3
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import router_dyndns.app as app_module import karldns.app as app_module
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app 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: def test_rate_limit_reject_does_not_increment_bucket(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3") 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:/", 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 False
store.flush_rate_limits() store.flush_rate_limits()
window = int(time.time()) // 60 window = int(time.time()) // 60
with store.connect() as conn: with store.connect() as conn:
count = conn.execute( count = conn.execute(
"SELECT count FROM rate_limits WHERE key = ? AND window = ?", "SELECT count FROM rate_limits WHERE key = ? AND window = ?",
("198.51.100.1:/magic", window), ("198.51.100.1:/", window),
).fetchone()[0] ).fetchone()[0]
assert count == 1 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"} 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: def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3" 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 cache_size == -20_000
assert temp_store == 2 assert temp_store == 2
assert mmap_size == 256 * 1024 * 1024 assert mmap_size == 256 * 1024 * 1024
assert user_version == 1 assert user_version == 3
assert { assert {
"idx_accounts_created_at", "idx_accounts_created_at",
"idx_rate_limits_window", "idx_rate_limits_window",
@@ -203,6 +251,58 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
}.issubset(index_names) }.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: def test_rate_limit_flush_requeues_counts_after_database_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3") store = DdnsStore(tmp_path / "ddns.sqlite3")
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
@@ -286,10 +386,10 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
store = DdnsStore(database_path) store = DdnsStore(database_path)
store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds) 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") 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) monkeypatch.setattr(store, "cleanup", fail_cleanup)
asyncio.run(app_module._run_cleanup_if_due(store, settings)) 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 fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, make_app from karldns import DdnsSettings, make_app
ROOT = Path(__file__).parents[1] 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) client = TestClient(app)
account = client.post("/api/v1/hostnames/magic", json={}).json() account = client.post("/api/v1/hostnames").json()
update_url = account["update_url"] update_url = account["update_url"]
script = tmp_path / "curl" script = tmp_path / "curl"
log_file = tmp_path / "curl.log" 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 f"""#!/bin/sh
printf '%s\\n' "$*" >> {log_file} printf '%s\\n' "$*" >> {log_file}
case "$*" in case "$*" in
*'/ip'*) printf '203.0.113.25\\n' ;;
*'<ipaddr>'*|*'<ip6addr>'*) printf 'bad placeholder leaked\\n'; exit 1 ;; *'<ipaddr>'*|*'<ip6addr>'*) printf 'bad placeholder leaked\\n'; exit 1 ;;
*'myip=203.0.113.25'*) printf 'good 203.0.113.25\\n' ;; *'myip=203.0.113.25'*) printf 'good 203.0.113.25\\n' ;;
*) printf 'bad request\\n'; exit 1 ;; *) printf 'bad request\\n'; exit 1 ;;
@@ -67,9 +66,9 @@ esac
env={ env={
"KARLDNS_UPDATE_URL": update_url, "KARLDNS_UPDATE_URL": update_url,
"KARLDNS_CURL": str(script), "KARLDNS_CURL": str(script),
"KARLDNS_IP_URL": "https://karldns.de/ip",
"KARLDNS_CACHE_FILE": str(cache_file), "KARLDNS_CACHE_FILE": str(cache_file),
"KARLDNS_LOCK_FILE": str(lock_file), "KARLDNS_LOCK_FILE": str(lock_file),
"KARLDNS_IPV4": "203.0.113.25",
"KARLDNS_IPV6": "off", "KARLDNS_IPV6": "off",
}, },
) )
@@ -77,3 +76,174 @@ esac
assert "good 203.0.113.25" in result.stdout assert "good 203.0.113.25" in result.stdout
assert cache_file.read_text(encoding="utf-8").strip() == "203.0.113.25|" assert cache_file.read_text(encoding="utf-8").strip() == "203.0.113.25|"
assert "<ipaddr>" not in log_file.read_text(encoding="utf-8") 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 import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service import karldns.services.ddns as ddns_module
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app from karldns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
from router_dyndns.ddns_dns import validate_dns_provider_settings from karldns.dns import validate_dns_provider_settings
from router_dyndns.ddns_security import DASHBOARD_UNLOCK_COOKIE from karldns.security import DASHBOARD_UNLOCK_COOKIE
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None: def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
@@ -20,14 +20,11 @@ def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
client = TestClient(app) client = TestClient(app)
response = client.get( response = client.get(
"/nic/update", f"/api/v1/router/update/{'a' * 43}",
params={ params={"hostname": "public-target.ddns.example.net", "myip": "203.0.113.9"},
"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() == [] assert store.list_records() == []
@@ -39,16 +36,13 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
client = TestClient(app) client = TestClient(app)
response = client.get( response = client.get(
"/nic/update", f"/api/v1/router/update/{account['update_slug']}",
params={ params={"hostname": "other.example.net", "myip": "203.0.113.9"},
"hostname": "other.example.net",
"myip": "203.0.113.9",
"username": account["username"],
"password": account["password"],
},
) )
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: 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: 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( app = make_app(
DdnsSettings( DdnsSettings(
database_path=tmp_path / "ddns.sqlite3", 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") account = app.state.store.create_account("home.example.net", "router")
client = TestClient(app) client = TestClient(app)
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.1"}) client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.1"})
client.get( client.get(f"/api/v1/hostnames/{account['management_slug']}")
"/nic/update",
params={
"hostname": account["hostname"],
"username": account["username"],
"password": account["password"],
"myip": "203.0.113.2",
},
)
assert "/u/<secret>" in caplog.text assert "/api/v1/router/update/<secret>" in caplog.text
assert "/nic/update?<redacted>" in caplog.text assert "/api/v1/hostnames/<secret>" in caplog.text
assert account["update_slug"] not 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( @pytest.mark.parametrize(
"path", "path",
[ [
"/u/short", "/api/v1/router/update/short",
"/api/v1/updates/short", "/api/v1/router/register/short",
"/api/v1/hostnames/short",
"/m/short", "/m/short",
"/api/v1/management/short",
], ],
) )
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None: 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) client = TestClient(app)
created = client.post("/magic", data={"username": "router"}) created = client.post("/")
assert created.status_code == 200 assert created.status_code == 200
assert created.headers["cache-control"] == "no-store" 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) client = TestClient(app)
response = client.post("/magic", data={"username": "x" * 2000}) response = client.post("/", data={"username": "x" * 2000})
assert response.status_code == 413 assert response.status_code == 413
@@ -208,7 +194,7 @@ def test_invalid_utf8_form_body_is_rejected(tmp_path: Path) -> None:
client = TestClient(app) client = TestClient(app)
response = client.post( response = client.post(
"/magic", "/",
content=b"username=\xff", content=b"username=\xff",
headers={"content-type": "application/x-www-form-urlencoded"}, 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)) body = "&".join(f"k{index}=v" for index in range(101))
response = client.post( response = client.post(
"/magic", "/",
content=body.encode("ascii"), content=body.encode("ascii"),
headers={"content-type": "application/x-www-form-urlencoded"}, 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) client = TestClient(app)
first = client.post("/magic", data={"username": "alice"}, headers={"x-forwarded-for": "198.51.100.10"}) first = client.post("/", headers={"x-forwarded-for": "198.51.100.10"})
second = client.post("/magic", data={"username": "bob"}, headers={"x-forwarded-for": "198.51.100.11"}) second = client.post("/", headers={"x-forwarded-for": "198.51.100.11"})
assert first.status_code == 200 assert first.status_code == 200
assert second.status_code == 429 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): def fail_delete(hostname, settings):
raise DnsProviderError("dns down") raise DnsProviderError("dns down")
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service") caplog.set_level(logging.WARNING, logger="karldns.services.ddns")
monkeypatch.setattr(ddns_service, "delete_dns_records", fail_delete) monkeypatch.setattr(ddns_module, "delete_dns_records", fail_delete)
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice") 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: 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} calls = {"find": 0}
@@ -394,7 +380,7 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
import dns.query import dns.query
import dns.rcode import dns.rcode
from router_dyndns.dns.rfc2136 import delete_rfc2136_records from karldns.dns.rfc2136 import delete_rfc2136_records
class RefusedResponse: class RefusedResponse:
def rcode(self) -> int: def rcode(self) -> int:
+127 -83
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi.testclient import TestClient 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: def _csrf(html: str) -> str:
@@ -23,14 +23,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
) )
client = TestClient(app) client = TestClient(app)
response = client.post("/accounts", data={"mode": "managed", "username": "alice"}) response = client.post("/")
assert response.status_code == 200 assert response.status_code == 200
assert "Update-URL:" in response.text assert "Update-URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text assert "Register URL:" in response.text
assert "Kennwort (optional):" in response.text
assert "Router fields" in response.text assert "Router fields" in response.text
assert "CNAME" in response.text assert "CNAME" in response.text
assert "Manual updates" 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 "Public:" in response.text
assert "Secret:" in response.text assert "Secret:" in response.text
assert ".ddns.example.net" in response.text assert ".ddns.example.net" in response.text
assert "/u/" in response.text assert "/api/v1/router/update/" in response.text
assert "/nic/update?" in response.text assert "/api/v1/router/register/" in response.text
assert "/m/" 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", database_path=tmp_path / "ddns.sqlite3",
admin_password="admin", admin_password="admin",
hostname_suffix="ddns.example.net", hostname_suffix="ddns.example.net",
public_base_url="https://karldns.de",
) )
) )
client = TestClient(app) client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"}) response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"})
assert response.status_code == 400 assert response.status_code == 404
assert response.json()["detail"] == "custom hostnames are not supported; create a KarlDNS hostname and use a CNAME"
assert app.state.store.count_accounts() == 0 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( app = make_app(
DdnsSettings( DdnsSettings(
database_path=tmp_path / "ddns.sqlite3", 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) client = TestClient(app)
response = client.post("/magic") response = client.post("/")
assert response.status_code == 200 assert response.status_code == 200
assert "Update-URL:" in response.text assert "Update-URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text assert "Register URL:" in response.text
assert "Kennwort (optional):" in response.text
assert "Private dashboard:" 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 "Router fields" in response.text
assert "CNAME" in response.text assert "CNAME" in response.text
assert "FRITZ!Box fills in" 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 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")) app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app) client = TestClient(app)
response = client.get("/magic", follow_redirects=False) response = client.get("/magic", follow_redirects=False)
assert response.status_code == 303 assert response.status_code == 404
assert response.headers["location"] == "/#create"
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" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home") 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 '<link href="/admin.css?v=2" rel="stylesheet">' in management.text
assert "home.ddns.example.net" in management.text assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Router fields" in management.text assert "Router fields" in management.text
assert "CNAME" in management.text assert "CNAME" in management.text
assert "Manual updates" 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)}) deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)})
assert deleted.status_code == 200 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 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 "Update-URL:" not in locked.text
assert "correct horse" 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.status_code == 200
assert update.text == "good 203.0.113.9" 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( bad_unlock = locked_client.post(
f"/m/{account['management_slug']}/unlock", f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"}, 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") 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" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router") 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']}") dashboard = client.get(f"/m/{account['management_slug']}")
assert dashboard.status_code == 200 assert dashboard.status_code == 200
assert "New update URL" in dashboard.text assert "New update URL" in dashboard.text
assert "New password" in dashboard.text
assert "data-secret-toggle" in dashboard.text assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text assert 'type="password"' in dashboard.text
@@ -357,26 +388,10 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
assert rotated_update.status_code == 200 assert rotated_update.status_code == 200
assert "Update URL changed. Update your router now." in rotated_update.text assert "Update URL changed. Update your router now." in rotated_update.text
assert "data-secret-input" in rotated_update.text assert "data-secret-input" in rotated_update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404 assert client.get(f"/api/v1/router/update/{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
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)} 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_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: 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 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("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 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"/api/v1/router/update/{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/{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: 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", database_path=tmp_path / "ddns.sqlite3",
admin_password="admin", admin_password="admin",
hostname_suffix="ddns.example.net", 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", 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("/") response = client.get("/")
assert response.status_code == 200 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://127.0.0.1" not in response.text
assert "http://localhost" 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: 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")) app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app) 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="/clipboard.js?v=3" defer></script>' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' not 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="/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 '<link href="/admin.css" rel="stylesheet">' not in response.text
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
assert "<style>" not in response.text assert "<style>" not in response.text
@@ -534,6 +575,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
assert "Made with" in response.text assert "Made with" in response.text
assert "❤️" in response.text assert "❤️" in response.text
assert "by karl" 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://github.com/StasonJatham/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text assert 'href="https://karl.fail"' in response.text
assert 'href="https://karlcom.de"' 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 "WebPage" in privacy.text
assert "Legal and privacy" in privacy.text assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text assert "Data processed" in privacy.text
assert "protected router passwords" in privacy.text assert "private update links" in privacy.text
assert "Made with" in privacy.text assert "Made with" in privacy.text
assert "❤️" in privacy.text assert "❤️" in privacy.text
assert "by karl" 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