Prepare KarlDNS production deployment
No files matched your search
@@ -25,6 +25,7 @@ DDNS_TTL=60
|
||||
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
|
||||
# DDNS_MAX_REQUEST_BODY_BYTES=16384
|
||||
# DDNS_REDACTED_ACCESS_LOG=0
|
||||
# DDNS_CLEANUP_PENDING_ACCOUNT_HOURS=24
|
||||
# DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
|
||||
# DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320
|
||||
# DDNS_CLEANUP_INTERVAL_SECONDS=3600
|
||||
|
||||
@@ -14,3 +14,4 @@ build/
|
||||
tmp/
|
||||
backend_audit_report.md
|
||||
security_best_practices_report.md
|
||||
output/playwright/
|
||||
@@ -1,12 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.14-alpine
|
||||
|
||||
ENV DDNS_DATABASE=/data/ddns.sqlite3
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md /app/
|
||||
COPY router_dyndns /app/router_dyndns
|
||||
RUN pip install --no-cache-dir .
|
||||
RUN adduser --system --group --home /app karldns \
|
||||
COPY pyproject.toml README.md requirements.txt /app/
|
||||
COPY karldns /app/karldns
|
||||
RUN pip install --no-cache-dir -r requirements.txt . \
|
||||
&& addgroup -S karldns \
|
||||
&& adduser -S -G karldns -h /app karldns \
|
||||
&& mkdir -p /data \
|
||||
&& chown -R karldns:karldns /app /data
|
||||
USER karldns
|
||||
|
||||
@@ -11,19 +11,19 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control
|
||||
## Why KarlDNS?
|
||||
|
||||
- **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure.
|
||||
- **FRITZ!Box compatible**: copy the generated `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` straight into the German FRITZ!Box DynDNS form.
|
||||
- **FRITZ!Box compatible**: copy the generated `Update-URL` and `Domainnamen` straight into the German FRITZ!Box DynDNS form.
|
||||
- **CNAME-friendly hostnames**: users can use the generated hostname directly, or point `home.example.com` to it with a CNAME.
|
||||
- **No registration required**: generated hostnames use private magic links, with no accounts or login flow.
|
||||
- **No registration required**: generated hostnames use private bearer links, with no accounts or login flow.
|
||||
- **CNAME alias support**: users create one KarlDNS hostname, then point `home.example.com` to it at their own DNS provider.
|
||||
- **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support.
|
||||
- **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
|
||||
- **API first**: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under `/api/v1`.
|
||||
- **API first**: small built-in API guide, OpenAPI JSON, and endpoints under `/api/v1`.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Create Router Credentials
|
||||
### Create A Hostname
|
||||
|
||||

|
||||

|
||||
|
||||
## Logo And Social Preview
|
||||
|
||||
@@ -31,12 +31,12 @@ KarlDNS ships web-ready brand assets so self-hosted instances and shared links l
|
||||
|
||||
| Asset | Purpose |
|
||||
| --- | --- |
|
||||
| `router_dyndns/assets/logo-nav.webp` | Small navigation logo. |
|
||||
| `router_dyndns/assets/logo.webp` | Optimized homepage logo. |
|
||||
| `router_dyndns/assets/logo-192.png` and `logo-512.png` | PWA, app icon, and manifest icons. |
|
||||
| `router_dyndns/assets/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, `apple-touch-icon.png` | Browser and mobile icons. |
|
||||
| `router_dyndns/assets/social-preview.png` | Open Graph and Twitter large-card preview for reliable crawlers. |
|
||||
| `router_dyndns/assets/social-preview.webp` | Optimized WebP social preview for modern clients. |
|
||||
| `karldns/assets/logo-nav.webp` | Small navigation logo. |
|
||||
| `karldns/assets/logo.webp` | Optimized homepage logo. |
|
||||
| `karldns/assets/logo-192.png` and `logo-512.png` | PWA, app icon, and manifest icons. |
|
||||
| `karldns/assets/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, `apple-touch-icon.png` | Browser and mobile icons. |
|
||||
| `karldns/assets/social-preview.png` | Open Graph and Twitter large-card preview for reliable crawlers. |
|
||||
| `karldns/assets/social-preview.webp` | Optimized WebP social preview for modern clients. |
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -44,10 +44,11 @@ KarlDNS lets you host your own managed DynDNS service:
|
||||
|
||||
1. A user opens your KarlDNS URL.
|
||||
2. They generate a random hostname like `a1b2c3d4.karldns.de`.
|
||||
3. KarlDNS shows the exact router settings for the DynDNS form.
|
||||
4. The router periodically calls the update URL with its current public IPv4/IPv6 address.
|
||||
5. KarlDNS publishes the matching DNS records through your configured DNS backend.
|
||||
6. If the user wants their own DNS name, they can create a CNAME to the generated hostname.
|
||||
3. KarlDNS shows the exact router settings and one-time secrets.
|
||||
4. The user confirms they saved the values, which registers the hostname.
|
||||
5. The router periodically calls the update URL with its current public IPv4/IPv6 address.
|
||||
6. KarlDNS publishes the matching DNS records through your configured DNS backend.
|
||||
7. If the user wants their own DNS name, they can create a CNAME to the generated hostname.
|
||||
|
||||
That means users always have a current hostname for VPN, remote access, self-hosted apps, home servers, NAS devices, and lab networks.
|
||||
|
||||
@@ -64,13 +65,13 @@ That means users always have a current hostname for VPN, remote access, self-hos
|
||||
|
||||
| Area | Support |
|
||||
| --- | --- |
|
||||
| Router update URLs | FRITZ!Box custom provider URL, `/u/<slug>`, and `/nic/update` compatibility |
|
||||
| Router update URLs | FRITZ!Box custom provider URL under `/api/v1/router/update/<secret>` |
|
||||
| DNS records | A and AAAA |
|
||||
| DNS providers | Cloudflare API and RFC 2136 dynamic DNS |
|
||||
| Generated hostnames | Random provider-owned names that can be used directly or as CNAME targets |
|
||||
| User-owned DNS names | CNAME only. KarlDNS publishes the generated provider-owned hostname; the user's DNS provider serves the alias |
|
||||
| Persistence | SQLite with WAL mode |
|
||||
| API | FastAPI, OpenAPI, Swagger UI, ReDoc |
|
||||
| API | FastAPI, small API docs page, OpenAPI JSON |
|
||||
| Auth model | No user accounts. Private bearer links for hostnames and dashboards |
|
||||
| Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example |
|
||||
|
||||
@@ -100,12 +101,12 @@ Open:
|
||||
|
||||
- Web UI: `http://localhost:8080/`
|
||||
- Admin UI: `http://localhost:8080/admin`
|
||||
- Swagger UI: `http://localhost:8080/docs`
|
||||
- ReDoc: `http://localhost:8080/redoc`
|
||||
- API guide: `http://localhost:8080/docs`
|
||||
- OpenAPI JSON: `http://localhost:8080/openapi.json`
|
||||
|
||||
Admin auth uses HTTP Basic auth. The username can be any value; the password is `DDNS_ADMIN_PASSWORD`.
|
||||
|
||||
The public OpenAPI schema documents the developer API under `/api/v1`. Admin/operator endpoints are intentionally omitted from Swagger UI and ReDoc.
|
||||
The API guide documents the public endpoints plus the authenticated operator API. OpenAPI JSON remains available for tools.
|
||||
|
||||
## Production Setup
|
||||
|
||||
@@ -144,35 +145,23 @@ export DDNS_TTL=60
|
||||
|
||||
## FRITZ!Box DynDNS Setup
|
||||
|
||||
Create a hostname in the web UI. For the normal accountless flow, the generated `Update-URL` already contains the secret update slug. Put that URL into the router and use the generated hostname as `Domainnamen`.
|
||||
|
||||
KarlDNS also shows optional compatibility fields for routers that insist on separate credentials:
|
||||
Create a hostname in the web UI, copy the generated values, then click **Register hostname**. The generated `Update-URL` contains the secret update slug. Put that URL into the router and use the generated hostname as `Domainnamen`.
|
||||
|
||||
- `Update-URL`
|
||||
- `Domainnamen`
|
||||
- `CNAME target`
|
||||
- `Benutzername` (optional)
|
||||
- `Kennwort` (optional)
|
||||
- `Private dashboard`
|
||||
- `Register URL`
|
||||
|
||||
Copy the router password and full update URL when they are generated or rotated. They are intentionally not recoverable from the dashboard later.
|
||||
Copy the private URLs when they are generated or rotated. They are intentionally hidden by default on the dashboard to reduce shoulder-surfing risk.
|
||||
|
||||
The generated FRITZ!Box URL uses the native placeholders:
|
||||
|
||||
```text
|
||||
https://karldns.de/u/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>
|
||||
https://karldns.de/api/v1/router/update/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>
|
||||
```
|
||||
|
||||
FRITZ!Box replaces `<ipaddr>` and `<ip6addr>` with the current WAN IPv4/IPv6 address and sends an HTTP GET request to that URL whenever it detects an address change. KarlDNS returns DynDNS-style responses such as `good 203.0.113.10`, `nochg 203.0.113.10`, `badauth`, or `nohost`.
|
||||
|
||||
This URL is enough for routers that only expose a single update URL field and do not send separate credentials. KarlDNS also shows a `Full update URL` that embeds `hostname`, `username`, and `password` as query parameters for clients that require credential-style DynDNS URLs but do not expose separate credential fields. Treat both URLs as secrets.
|
||||
|
||||
The compatibility endpoint is also available:
|
||||
|
||||
```text
|
||||
https://karldns.de/nic/update?hostname=<generated-hostname>&myip=<ipaddr>&myipv6=<ip6addr>&username=<username>&password=<pass>
|
||||
```
|
||||
|
||||
Prefer `/u/<slug>` for managed service use because the router request cannot change the hostname.
|
||||
FRITZ!Box replaces `<ipaddr>` and `<ip6addr>` with the current WAN IPv4/IPv6 address and sends an HTTP GET request whenever it detects an address change. KarlDNS returns simple DynDNS-style responses such as `good 203.0.113.10`, `nochg 203.0.113.10`, `badip`, or `911 dns publish failed`.
|
||||
|
||||
## Manual Updates And Automation
|
||||
|
||||
@@ -180,24 +169,26 @@ Every generated hostname can be updated with curl, cron, a shell script, Home As
|
||||
|
||||
```bash
|
||||
# Explicit IPv4 update
|
||||
curl -sS 'https://karldns.de/u/<random-update-slug>?myip=203.0.113.10'
|
||||
curl -sS 'https://karldns.de/api/v1/router/update/<random-update-slug>?myip=203.0.113.10'
|
||||
|
||||
# Explicit IPv6 update
|
||||
curl -sS 'https://karldns.de/u/<random-update-slug>?myipv6=2001:db8::10'
|
||||
curl -sS 'https://karldns.de/api/v1/router/update/<random-update-slug>?myipv6=2001:db8::10'
|
||||
|
||||
# Use the request source IP
|
||||
curl -sS 'https://karldns.de/u/<random-update-slug>'
|
||||
```
|
||||
|
||||
The `/nic/update` compatibility endpoint also works for clients that expect hostname and credentials in the URL:
|
||||
|
||||
```bash
|
||||
curl -sS 'https://karldns.de/nic/update?hostname=a1b2c3d4.karldns.de&username=router&password=<password>&myip=203.0.113.10'
|
||||
curl -sS 'https://karldns.de/api/v1/router/update/<random-update-slug>'
|
||||
```
|
||||
|
||||
### Linux Cron Installer
|
||||
|
||||
For Linux servers, Proxmox hosts, NAS boxes, and small home-lab machines that are always on, KarlDNS ships a cron updater. It creates a hostname if you do not pass one, installs a tiny updater script, schedules it with cron, runs the first update, and prints the hostname plus private dashboard URL.
|
||||
For Linux servers, Proxmox hosts, NAS boxes, and small home-lab machines that are always on, KarlDNS ships a cron updater. It creates and registers a hostname if you do not pass one, installs a tiny updater script, schedules it with cron, runs the first update, and prints the hostname plus private dashboard URL.
|
||||
|
||||
The installer is POSIX `sh` and works on common Linux distributions including Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts. It needs `curl` and cron. On Alpine, install those first if they are not present:
|
||||
|
||||
```bash
|
||||
apk add curl
|
||||
rc-service crond start
|
||||
rc-update add crond
|
||||
```
|
||||
|
||||
System-wide install:
|
||||
|
||||
@@ -211,17 +202,11 @@ The same script is hosted in GitHub and can be used directly:
|
||||
curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sudo sh
|
||||
```
|
||||
|
||||
User-level install without sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user
|
||||
```
|
||||
|
||||
Use an existing KarlDNS update URL:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://karldns.de/install.sh | sudo sh -s -- \
|
||||
--update-url 'https://karldns.de/u/<random-update-slug>' \
|
||||
--update-url 'https://karldns.de/api/v1/router/update/<random-update-slug>' \
|
||||
--hostname 'home.karldns.de'
|
||||
```
|
||||
|
||||
@@ -229,23 +214,29 @@ Common options:
|
||||
|
||||
```bash
|
||||
--every 5 # cron interval in minutes, default 5
|
||||
--mode user # install without root into ~/.local, ~/.config, and the user's crontab
|
||||
--ipv4 auto # auto, off, or a static IPv4
|
||||
--ipv6 auto # auto, off, or a static IPv6
|
||||
--no-cron # install files only
|
||||
--no-run # skip the first update
|
||||
```
|
||||
|
||||
The updater discovers the public address through `https://karldns.de/ip` and sends it to the private update URL. If explicit discovery fails, it falls back to calling the update URL without IP parameters so KarlDNS can use the request source address. Re-running the installer is safe: it replaces the managed cron block and keeps unrelated cron entries intact.
|
||||
The updater calls the private update URL. By default KarlDNS uses the request source address; explicit `--ipv4` or `--ipv6` values can be configured when needed. Re-running the installer is safe: it reuses an existing `updater.env`, refreshes the updater script, replaces only the managed cron block, and keeps unrelated cron entries intact.
|
||||
|
||||
Installed files in system mode:
|
||||
|
||||
```text
|
||||
/usr/local/bin/karldns-update
|
||||
/etc/karldns/updater.env
|
||||
/etc/cron.d/karldns-updater
|
||||
/var/cache/karldns/last-ip
|
||||
```
|
||||
|
||||
Cron is installed where the OS expects it:
|
||||
|
||||
- Debian/Ubuntu/Fedora/Arch style: `/etc/cron.d/karldns-updater`
|
||||
- Alpine/BusyBox style: managed block in `/etc/crontabs/root`
|
||||
- Fallback: managed block in root's `crontab`
|
||||
|
||||
Installed files in user mode:
|
||||
|
||||
```text
|
||||
@@ -254,7 +245,7 @@ Installed files in user mode:
|
||||
~/.cache/karldns/last-ip
|
||||
```
|
||||
|
||||
`updater.env` is written with mode `600` because it contains the private update URL and dashboard URL. The hostname itself is not secret. The update URL and dashboard URL are secrets.
|
||||
`updater.env` is written with mode `600` because it contains private URLs. The hostname itself is not secret. The update URL, registration URL, and dashboard URL are secrets.
|
||||
|
||||
Uninstall, keeping the private config by default:
|
||||
|
||||
@@ -262,7 +253,7 @@ Uninstall, keeping the private config by default:
|
||||
curl -fsSL https://karldns.de/uninstall.sh | sudo sh
|
||||
```
|
||||
|
||||
Remove the private config and last-IP cache too:
|
||||
Clean uninstall, including saved private URLs:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://karldns.de/uninstall.sh | sudo sh -s -- --delete-config
|
||||
@@ -282,31 +273,52 @@ To use your own domain, create a KarlDNS hostname first, then create a CNAME at
|
||||
home.example.com. CNAME a1b2c3d4.karldns.de.
|
||||
```
|
||||
|
||||
Put the KarlDNS `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` into the router. The router updates `a1b2c3d4.karldns.de`; `home.example.com` follows through the CNAME.
|
||||
Put the KarlDNS `Update-URL` and `Domainnamen` into the router. The router updates `a1b2c3d4.karldns.de`; `home.example.com` follows through the CNAME.
|
||||
|
||||
Use a subdomain such as `home.example.com`. Root/apex domains like `example.com` usually cannot be plain CNAME records unless your DNS provider supports CNAME flattening or ALIAS/ANAME records.
|
||||
|
||||
KarlDNS does not verify, claim, or publish records inside user-owned domains. Your DNS provider keeps serving `home.example.com`; KarlDNS only maintains the generated `*.karldns.de` target.
|
||||
|
||||
The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, router password, private dashboard URL, and full credentialed update URL are the secrets. Dashboard forms use CSRF tokens, and dashboard password unlock cookies last 24 hours by default unless the user chooses the 30-day stay-unlocked option.
|
||||
The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, registration URL, and private dashboard URL are the secrets. Dashboard forms use CSRF tokens, and dashboard password unlock cookies last 24 hours by default unless the user chooses the 30-day stay-unlocked option.
|
||||
|
||||
## HTTP API
|
||||
|
||||
The JSON API is available under `/api/v1`.
|
||||
The JSON API is available under `/api/v1`. The public API has three surfaces: admin data, user dashboard data with history, and router registration/update calls.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/hostnames/magic` | Create an anonymous provider-owned hostname. |
|
||||
| `GET /api/v1/management/{management_slug}` | Inspect a hostname by private management link. |
|
||||
| `DELETE /api/v1/management/{management_slug}` | Delete a hostname and its DNS records. |
|
||||
| `GET /api/v1/updates/{update_slug}` | JSON update endpoint for routers or automation. |
|
||||
| `POST /api/v1/hostnames` | Create an anonymous provider-owned hostname in pending state. |
|
||||
| `GET /api/v1/hostnames/{dashboard_secret}` | Inspect a hostname and recent update history by private dashboard secret. |
|
||||
| `POST /api/v1/hostnames/{dashboard_secret}/rotate-update-url` | Replace the private router update URL. |
|
||||
| `DELETE /api/v1/hostnames/{dashboard_secret}` | Delete a hostname and its DNS records. |
|
||||
| `GET or POST /api/v1/router/register/{dashboard_secret}` | Register a pending hostname after secrets are saved. |
|
||||
| `GET or POST /api/v1/router/update/{update_secret}` | Update a hostname from FRITZ!Box, another router, curl, or a cron script. |
|
||||
| `GET /api/v1/admin/dashboard` | Authenticated operator summary, metrics, cleanup state, and recent history. |
|
||||
| `GET /api/v1/admin/events` | Authenticated paginated update history. |
|
||||
| `GET /api/v1/admin/hostnames` | Authenticated paginated hostname inventory. |
|
||||
| `GET /api/v1/admin/records` | Authenticated paginated DNS record inventory. |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -sS https://karldns.de/api/v1/hostnames/magic \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"home-router"}'
|
||||
curl -sS -X POST https://karldns.de/api/v1/hostnames
|
||||
```
|
||||
|
||||
The create response includes `registration_url`, `update_url`, and `dashboard_url`. Save the returned secrets, then `POST` the registration URL before using the update URL. Registration publishes the initial DNS record from the request IP; scripts may also pass `myip` or `myipv6` on the registration URL. Pending hostnames that are never registered are cleaned up automatically.
|
||||
|
||||
Use the dashboard secret to rotate the update URL:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://karldns.de/api/v1/hostnames/<dashboard-secret>/rotate-update-url
|
||||
```
|
||||
|
||||
The response contains a replacement `update_url`. The old update URL stops working immediately.
|
||||
|
||||
Operator API calls use the same HTTP Basic auth password as the admin UI:
|
||||
|
||||
```bash
|
||||
curl -sS -u admin:<admin-password> https://karldns.de/api/v1/admin/dashboard
|
||||
curl -sS -u admin:<admin-password> 'https://karldns.de/api/v1/admin/events?limit=25'
|
||||
```
|
||||
|
||||
## Docker
|
||||
@@ -320,7 +332,7 @@ docker compose up -d --build
|
||||
|
||||
The example environment is intentionally short: admin password/path, dashboard cookie signing secret, public URL, managed hostname suffix, Cloudflare token, Cloudflare zone ID, and the SQLite path. The app creates the SQLite parent directory and all tables on first run. Re-running the container or starting the app twice is idempotent; schema creation uses `CREATE TABLE IF NOT EXISTS`, and the cleanup scheduler keeps a single durable task row.
|
||||
|
||||
The image and compose file default `DDNS_DATABASE` to `/data/ddns.sqlite3`, and the compose volume persists `/data`.
|
||||
The image uses the current Python 3.14 Alpine base image and installs production dependencies from `requirements.txt`. The compose file defaults `DDNS_DATABASE` to `/data/ddns.sqlite3`, and the compose volume persists `/data`.
|
||||
|
||||
The default container command runs one Uvicorn worker. Keep that deployment shape unless you have tested multi-worker update behavior for your traffic pattern; rate-limit counters and per-host update locks are process-local.
|
||||
|
||||
@@ -333,10 +345,11 @@ The default container command runs one Uvicorn worker. Keep that deployment shap
|
||||
- Use a long random value for `DDNS_ADMIN_PASSWORD`.
|
||||
- Use a separate long random value for `DDNS_DASHBOARD_COOKIE_SECRET` on public instances.
|
||||
- Change `DDNS_ADMIN_PATH` if you do not want the operator UI at `/admin`.
|
||||
- Set `DDNS_PUBLIC_BASE_URL` to the real public URL. KarlDNS uses it for generated links, code examples, API docs, canonical URLs, and the default trusted host.
|
||||
- Use a least-privilege DNS API token or TSIG key.
|
||||
- Set `DDNS_TRUSTED_HOSTS` only when the app should answer for hostnames beyond `DDNS_PUBLIC_BASE_URL`.
|
||||
- Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers.
|
||||
- Keep `/docs` and `/redoc` available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth.
|
||||
- Keep `/docs` available if you want the small developer surface. Operator API routes are documented and require Basic auth; admin UI form routes stay hidden.
|
||||
- Leave cleanup enabled. The cleanup scheduler is stored in SQLite with a task row, next run time, status, last error, and a database lease so only one app process runs a due cleanup job.
|
||||
- Back up `/data/ddns.sqlite3`.
|
||||
- Run one Uvicorn worker with SQLite unless you have tested your deployment under load. Scheduled cleanup is lease-protected across workers, but rate-limit state and per-host update locks are process-local.
|
||||
@@ -349,11 +362,13 @@ Security and cleanup defaults:
|
||||
| `DDNS_RATE_LIMIT_PER_MINUTE` | `60` | Public update/API/form requests allowed per source IP and path. |
|
||||
| `DDNS_ADMIN_RATE_LIMIT_PER_MINUTE` | `20` | Admin/operator requests allowed per source IP and path. |
|
||||
| `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. |
|
||||
| `DDNS_ADMIN_PATH` | `/admin` | Private operator UI path. Admin routes are hidden from OpenAPI and require Basic auth. |
|
||||
| `DDNS_ADMIN_PATH` | `/admin` | Private operator UI path. Admin UI form routes stay hidden from OpenAPI and require Basic auth. |
|
||||
| `DDNS_PUBLIC_BASE_URL` | `http://localhost:8080` | Public URL used for generated links, public examples, API docs, canonical URLs, and the default trusted host. |
|
||||
| `DDNS_DASHBOARD_COOKIE_SECRET` | `DDNS_ADMIN_PASSWORD` fallback | HMAC signing secret for dashboard unlock cookies. Use a separate 32+ character random value in production. |
|
||||
| `DDNS_REDACTED_ACCESS_LOG` | `0` | Enable KarlDNS request logs with bearer paths and update query strings redacted. |
|
||||
| `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. |
|
||||
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete generated hostnames that never received a router update after this many hours. |
|
||||
| `DDNS_CLEANUP_PENDING_ACCOUNT_HOURS` | `24` | Delete generated setup intents that were never registered after this many hours. |
|
||||
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete registered generated hostnames that never received a router update after this many hours. |
|
||||
| `DDNS_CLEANUP_EVENT_HISTORY_HOURS` | `4320` | Delete old raw update/audit events after this many hours. Rollups and recent events remain visible. |
|
||||
| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Store the cleanup task cadence in SQLite and schedule the next cleanup run at this interval. |
|
||||
| `DDNS_DASHBOARD_UNLOCK_SECONDS` | `86400` | Default dashboard password unlock cookie lifetime. |
|
||||
@@ -375,22 +390,21 @@ Backend layout:
|
||||
|
||||
| Module | Purpose |
|
||||
| --- | --- |
|
||||
| `router_dyndns/app.py` | FastAPI app factory, lifespan, middleware and router registration. |
|
||||
| `router_dyndns/ddns.py` | Compatibility exports for existing imports and CLI users. |
|
||||
| `router_dyndns/routers/` | Module-level `APIRouter` files for API, public web, management, admin, assets, and DynDNS update routes. |
|
||||
| `router_dyndns/dependencies.py` | Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. |
|
||||
| `router_dyndns/schemas.py` | Pydantic request and response models used by the JSON API. |
|
||||
| `router_dyndns/web/` | HTML rendering, form parsing, and security-header/body-limit support code. |
|
||||
| `router_dyndns/ddns_service.py` | Domain logic for account creation and IP updates. |
|
||||
| `router_dyndns/store/` | SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. |
|
||||
| `router_dyndns/ddns_store.py` | Compatibility export for `DdnsStore`. |
|
||||
| `router_dyndns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
|
||||
| `router_dyndns/ddns_dns.py` | Compatibility exports for DNS provider helpers. |
|
||||
| `karldns/app.py` | FastAPI app factory, lifespan, middleware and router registration. |
|
||||
| `karldns/settings.py` | Pydantic settings loaded from environment variables. |
|
||||
| `karldns/models.py` | Shared domain models such as update results. |
|
||||
| `karldns/routers/` | Module-level `APIRouter` files for API, public web, management, admin, assets, and DynDNS update routes. |
|
||||
| `karldns/dependencies.py` | Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. |
|
||||
| `karldns/schemas.py` | Pydantic request and response models used by the JSON API. |
|
||||
| `karldns/web/` | Layout, reusable HTML components, scripts, CSS, form parsing, and security-header/body-limit support code. |
|
||||
| `karldns/services/ddns.py` | Domain logic for account creation and IP updates. |
|
||||
| `karldns/store/` | SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. |
|
||||
| `karldns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
|
||||
|
||||
```bash
|
||||
ruff check router_dyndns tests
|
||||
ruff check karldns tests
|
||||
pytest
|
||||
python -m compileall router_dyndns
|
||||
python -m compileall karldns
|
||||
```
|
||||
|
||||
## SEO Keywords
|
||||
|
||||
@@ -9,9 +9,9 @@ Static templates and asset JavaScript use a small Bootstrap surface:
|
||||
- Layout: `container`, `row`, `col-*`, `d-flex`, `d-grid`, `gap-*`, responsive flex alignment, spacing utilities, `w-100`, `h-100`.
|
||||
- Components: `alert`, `badge`, `btn`, `card`, `form-control`, `form-select`, `form-check`, `input-group`, `nav`, `navbar`, `pagination`, `table`.
|
||||
- Helpers/utilities: `border*`, `rounded*`, `text-*`, `text-bg-*`, `text-truncate`, `visually-hidden`, `opacity-75`, `small`.
|
||||
- JavaScript-created classes in `router_dyndns/routers/assets.py`: `page-item`, `disabled`, `active`, `page-link`, `empty-row`, `empty`.
|
||||
- JavaScript-created classes in `karldns/web/scripts.py`: `page-item`, `disabled`, `active`, `page-link`, `empty-row`, `empty`.
|
||||
|
||||
The app CSS in `router_dyndns/web/styles.py` already owns the custom KarlDNS look. Bootstrap is mainly supplying reset, grid, forms, buttons, tables, navigation, pagination, and common utilities.
|
||||
The app CSS in `karldns/web/styles.py` already owns the custom KarlDNS look. Bootstrap is mainly supplying reset, grid, forms, buttons, tables, navigation, pagination, and common utilities.
|
||||
|
||||
## Proposed Build
|
||||
|
||||
@@ -34,7 +34,7 @@ Dry-run result on this worktree:
|
||||
|
||||
Keep the current app references unchanged until the subset is visually tested. The safe implementation sequence is:
|
||||
|
||||
1. Run `python3 scripts/build-bootstrap-subset.py --write` to create `router_dyndns/assets/bootstrap-karldns.min.css`.
|
||||
1. Run `python3 scripts/build-bootstrap-subset.py --write` to create `karldns/assets/bootstrap-karldns.min.css`.
|
||||
2. Add an isolated asset route for `/bootstrap-karldns.min.css`.
|
||||
3. Switch `_page()` from `/bootstrap-5.3.8.min.css?v=2` to `/bootstrap-karldns.min.css?v=1`.
|
||||
4. Run UI tests and browser-check public, dashboard, locked dashboard, admin tables, pagination, dark mode, and copy fields.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -11,11 +11,12 @@ from fastapi import FastAPI
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
from .ddns_dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
|
||||
from .ddns_models import DdnsSettings, UpdateResult
|
||||
from .ddns_service import DdnsService
|
||||
from .ddns_store import DdnsStore
|
||||
from .dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
|
||||
from .models import UpdateResult
|
||||
from .routers import admin, api, assets, management, public, updates
|
||||
from .services.ddns import DdnsService
|
||||
from .settings import DdnsSettings
|
||||
from .store import DdnsStore
|
||||
from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware
|
||||
|
||||
__all__ = [
|
||||
@@ -37,12 +38,12 @@ OPENAPI_TAGS = [
|
||||
"description": "Create KarlDNS hostnames that can be used directly or as CNAME targets.",
|
||||
},
|
||||
{
|
||||
"name": "updates",
|
||||
"description": "Router-facing IP update endpoints for FRITZ!Box and compatible clients.",
|
||||
"name": "router",
|
||||
"description": "Router-facing registration and IP update endpoints for simple URL clients.",
|
||||
},
|
||||
{
|
||||
"name": "management",
|
||||
"description": "API management through private dashboard links. Dashboard passwords protect only the web dashboard, not these API routes.",
|
||||
"name": "admin",
|
||||
"description": "Operator API endpoints protected by the same admin HTTP Basic auth as the web admin console.",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -84,8 +85,8 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
||||
"for KarlDNS-managed hostnames through Cloudflare or RFC 2136."
|
||||
),
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
@@ -135,6 +136,7 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None:
|
||||
store.cleanup,
|
||||
settings.cleanup_unused_account_hours,
|
||||
settings.cleanup_event_history_hours,
|
||||
settings.cleanup_pending_account_hours,
|
||||
)
|
||||
except Exception as exc:
|
||||
LOGGER.exception("Scheduled cleanup failed")
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -2,7 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .ddns import DdnsSettings, make_app
|
||||
from .app import make_app
|
||||
from .settings import DdnsSettings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -11,7 +12,7 @@ def main() -> None:
|
||||
|
||||
render_url = subparsers.add_parser("render-fritz-url", help="Print a FRITZ!Box DynDNS update URL.")
|
||||
render_url.add_argument("--base-url", required=True, help="Public KarlDNS URL.")
|
||||
render_url.add_argument("--host", required=True, help="DNS hostname configured in FRITZ!Box.")
|
||||
render_url.add_argument("--update-secret", required=True, help="Private router update secret.")
|
||||
render_url.add_argument("--include-ipv6", action="store_true")
|
||||
|
||||
serve = subparsers.add_parser("serve", help="Run the KarlDNS web service.")
|
||||
@@ -25,7 +26,7 @@ def main() -> None:
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "render-fritz-url":
|
||||
print(_render_fritz_url(args.base_url, args.host, args.include_ipv6))
|
||||
print(_render_fritz_url(args.base_url, args.update_secret, args.include_ipv6))
|
||||
elif args.command == "serve":
|
||||
import uvicorn
|
||||
|
||||
@@ -37,12 +38,12 @@ def main() -> None:
|
||||
uvicorn.run(make_app(settings), host=args.host, port=args.port, access_log=False)
|
||||
|
||||
|
||||
def _render_fritz_url(base_url: str, host: str, include_ipv6: bool) -> str:
|
||||
def _render_fritz_url(base_url: str, update_secret: str, include_ipv6: bool) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
url = f"{base}/nic/update?hostname=<domain>&myip=<ipaddr>&username=<username>&password=<pass>"
|
||||
url = f"{base}/api/v1/router/update/{update_secret}?myip=<ipaddr>"
|
||||
if include_ipv6:
|
||||
url += "&myipv6=<ip6addr>"
|
||||
return url.replace("<domain>", host)
|
||||
return url
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .ddns_models import DdnsSettings
|
||||
from .settings import DdnsSettings
|
||||
|
||||
|
||||
def settings_from_env() -> DdnsSettings:
|
||||
@@ -27,6 +27,7 @@ def settings_from_env() -> DdnsSettings:
|
||||
require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"),
|
||||
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
|
||||
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
|
||||
cleanup_pending_account_hours=_env_int("DDNS_CLEANUP_PENDING_ACCOUNT_HOURS", 24),
|
||||
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080),
|
||||
cleanup_event_history_hours=_env_int("DDNS_CLEANUP_EVENT_HISTORY_HOURS", 24 * 180),
|
||||
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
|
||||
@@ -3,14 +3,14 @@ from __future__ import annotations
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Query, Request
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from .ddns_dns import DnsProviderError
|
||||
from .ddns_models import DdnsSettings
|
||||
from .ddns_security import credentials_from_basic_auth
|
||||
from .ddns_service import BadIpError, DdnsService
|
||||
from .ddns_store import DdnsStore
|
||||
from .dns import DnsProviderError
|
||||
from .security import credentials_from_basic_auth
|
||||
from .services.ddns import BadIpError, DdnsService
|
||||
from .settings import DdnsSettings
|
||||
from .store import DdnsStore
|
||||
|
||||
|
||||
def get_settings(request: Request) -> DdnsSettings:
|
||||
@@ -30,20 +30,6 @@ StoreDep = Annotated[DdnsStore, Depends(get_store)]
|
||||
ServiceDep = Annotated[DdnsService, Depends(get_service)]
|
||||
|
||||
|
||||
def authenticate_update(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
username: str | None = Query(default=None),
|
||||
password: str | None = Query(default=None),
|
||||
pass_: str | None = Query(default=None, alias="pass"),
|
||||
passwd: str | None = Query(default=None),
|
||||
) -> tuple[str | None, str | None]:
|
||||
supplied_user = username
|
||||
supplied_token = password or pass_ or passwd
|
||||
if authorization and authorization.lower().startswith("basic "):
|
||||
supplied_user, supplied_token = credentials_from_basic_auth(authorization)
|
||||
return supplied_user, supplied_token
|
||||
|
||||
|
||||
def authenticate_admin(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..ddns_models import DdnsSettings, UpdateResult
|
||||
from ..models import UpdateResult
|
||||
from ..settings import DdnsSettings
|
||||
|
||||
SUPPORTED_DNS_PROVIDERS = {"", "cloudflare", "rfc2136"}
|
||||
|
||||
@@ -5,7 +5,8 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from ..ddns_models import DdnsSettings, UpdateResult
|
||||
from ..models import UpdateResult
|
||||
from ..settings import DdnsSettings
|
||||
from .base import DnsProviderError, DnsReconciliationError
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..ddns_models import DdnsSettings, UpdateResult
|
||||
from ..models import UpdateResult
|
||||
from ..settings import DdnsSettings
|
||||
from .base import DnsProviderError
|
||||
|
||||
|
||||
@@ -10,10 +10,9 @@ DASHBOARD_PASSWORD_CHANGED = "dashboard_password_changed"
|
||||
DASHBOARD_PASSWORD_ENABLED = "dashboard_password_enabled"
|
||||
DASHBOARD_PASSWORD_REMOVED = "dashboard_password_removed"
|
||||
DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated"
|
||||
DASHBOARD_ROUTER_CREDENTIALS_ROTATED = "dashboard_router_credentials_rotated"
|
||||
DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted"
|
||||
DASHBOARD_HOSTNAME_ACTIVATED = "dashboard_hostname_activated"
|
||||
|
||||
ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted"
|
||||
ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated"
|
||||
ADMIN_MANAGEMENT_URL_ROTATED = "admin_management_url_rotated"
|
||||
ADMIN_ROUTER_CREDENTIALS_ROTATED = "admin_router_credentials_rotated"
|
||||
@@ -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}"
|
||||
@@ -4,18 +4,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_dns import DnsProviderError
|
||||
from ..ddns_security import client_ip, require_admin_csrf
|
||||
from ..ddns_service import normalize_hostname
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
|
||||
from ..dns import DnsProviderError
|
||||
from ..event_statuses import (
|
||||
ADMIN_HOSTNAME_DELETED,
|
||||
ADMIN_MANAGEMENT_URL_ROTATED,
|
||||
ADMIN_ROUTER_CREDENTIALS_ROTATED,
|
||||
ADMIN_UPDATE_URL_ROTATED,
|
||||
)
|
||||
from ..security import client_ip, require_admin_csrf
|
||||
from ..services.ddns import normalize_hostname
|
||||
from ..web.components import _copy_row
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.templates import _copy_row
|
||||
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
|
||||
|
||||
ADMIN_TABLE_LIMIT = 100
|
||||
@@ -24,8 +23,6 @@ ADMIN_JSON_OFFSET_CAP = 10_000
|
||||
|
||||
def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
router = APIRouter()
|
||||
records_path = f"{admin_path}/records"
|
||||
events_path = f"{admin_path}/events"
|
||||
|
||||
def render_admin(
|
||||
service: ServiceDep,
|
||||
@@ -74,7 +71,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
},
|
||||
)
|
||||
|
||||
@router.post(f"{admin_path}/accounts/delete", include_in_schema=False)
|
||||
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
|
||||
async def admin_delete_account(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
@@ -101,7 +98,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
)
|
||||
return RedirectResponse(admin_path, status_code=303)
|
||||
|
||||
@router.post(f"{admin_path}/accounts/rotate", include_in_schema=False)
|
||||
@router.post(f"{admin_path}/hostnames/rotate", include_in_schema=False)
|
||||
async def admin_rotate_account(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
@@ -129,7 +126,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
"router update URL rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
notice = _admin_secret_panel("Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
|
||||
notice = _admin_secret_panel("Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated), "Secret")]) if rotated else None
|
||||
elif action == "management":
|
||||
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
|
||||
await run_in_threadpool(
|
||||
@@ -138,31 +135,10 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
None,
|
||||
None,
|
||||
ADMIN_MANAGEMENT_URL_ROTATED,
|
||||
"management URL rotated by admin",
|
||||
"dashboard link rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.magic_management_url(rotated))]) if rotated else None
|
||||
elif action == "password":
|
||||
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
ADMIN_ROUTER_CREDENTIALS_ROTATED,
|
||||
"router credentials rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
if rotated:
|
||||
notice = _admin_secret_panel(
|
||||
"Router password changed",
|
||||
[
|
||||
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
|
||||
_copy_row("Full update URL:", service.credentialed_update_url(rotated), "Secret"),
|
||||
_copy_row("Benutzername (optional):", str(rotated["username"]), "Public"),
|
||||
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
|
||||
],
|
||||
)
|
||||
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")]) if rotated else None
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="invalid rotation action")
|
||||
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
|
||||
@@ -182,6 +158,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
store.cleanup,
|
||||
settings.cleanup_unused_account_hours,
|
||||
settings.cleanup_event_history_hours,
|
||||
settings.cleanup_pending_account_hours,
|
||||
)
|
||||
notice = _admin_secret_panel(
|
||||
"Cleanup done",
|
||||
@@ -193,42 +170,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
|
||||
return HTMLResponse(page)
|
||||
|
||||
@router.get(records_path, include_in_schema=False)
|
||||
def records(
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"items": store.list_records_page(limit=limit, offset=offset),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"total": store.count_records(),
|
||||
}
|
||||
|
||||
@router.get(events_path, include_in_schema=False)
|
||||
def events(
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
|
||||
before: str | None = Query(None, max_length=64),
|
||||
) -> dict[str, object]:
|
||||
items = store.list_update_events_before(limit=limit, before=before) if before else store.list_update_events_page(limit=limit, offset=offset)
|
||||
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
|
||||
return {
|
||||
"items": items,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"next_before": next_before,
|
||||
"total": store.count_update_events(),
|
||||
}
|
||||
|
||||
if admin_path == "/admin":
|
||||
router.add_api_route("/records", records, methods=["GET"], include_in_schema=False)
|
||||
router.add_api_route("/events", events, methods=["GET"], include_in_schema=False)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
|
||||
from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS
|
||||
from ..web.styles import ADMIN_CSS, BASE_CSS
|
||||
|
||||
ASSET_DIR = Path(__file__).parents[1] / "assets"
|
||||
router = APIRouter(include_in_schema=False)
|
||||
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||
|
||||
|
||||
def _minify_css(css: str) -> str:
|
||||
return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}")
|
||||
|
||||
|
||||
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
|
||||
MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
|
||||
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
def favicon() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/bootstrap-subset.css")
|
||||
def bootstrap_subset_css() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/favicon-16x16.png")
|
||||
def favicon_16_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/favicon-32x32.png")
|
||||
def favicon_32_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/apple-touch-icon.png")
|
||||
def apple_touch_icon_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo.png")
|
||||
def logo_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo.webp")
|
||||
def logo_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-nav.webp")
|
||||
def logo_nav_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-192.png")
|
||||
def logo_192_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-512.png")
|
||||
def logo_512_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/social-preview.png")
|
||||
def social_preview_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/social-preview.webp")
|
||||
def social_preview_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/site.webmanifest")
|
||||
def site_webmanifest() -> Response:
|
||||
return Response(
|
||||
"""
|
||||
{
|
||||
"name": "KarlDNS",
|
||||
"short_name": "KarlDNS",
|
||||
"description": "Private update links for routers.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
""".strip(),
|
||||
media_type="application/manifest+json",
|
||||
headers=ASSET_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
@router.get("/theme.js")
|
||||
def theme_js() -> Response:
|
||||
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/clipboard.js")
|
||||
def clipboard_js() -> Response:
|
||||
return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/admin-tables.js")
|
||||
def admin_tables_js() -> Response:
|
||||
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/app.css")
|
||||
def app_css() -> Response:
|
||||
return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/admin.css")
|
||||
def admin_css() -> Response:
|
||||
return Response(MINIFIED_ADMIN_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/install.sh")
|
||||
def install_sh() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/uninstall.sh")
|
||||
def uninstall_sh() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/karldns-update.sh")
|
||||
def karldns_update_sh() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/karldns-update.sh",
|
||||
status_code=302,
|
||||
)
|
||||
@@ -3,27 +3,33 @@ from __future__ import annotations
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_dns import DnsProviderError
|
||||
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
||||
from ..dns import DnsProviderError
|
||||
from ..event_statuses import (
|
||||
DASHBOARD_HOSTNAME_ACTIVATED,
|
||||
DASHBOARD_HOSTNAME_DELETED,
|
||||
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
|
||||
DASHBOARD_UPDATE_URL_ROTATED,
|
||||
)
|
||||
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
from ..services.ddns import BadIpError
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.html_utils import trusted
|
||||
from ..web.templates import _page
|
||||
from ..web.templates_management import _render_management_page, _render_management_unlock_page
|
||||
from ..web.layout import _page
|
||||
from ..web.templates_management import (
|
||||
_render_management_page,
|
||||
_render_management_unlock_page,
|
||||
_render_pending_management_page,
|
||||
)
|
||||
from . import management_passwords
|
||||
from .management_shared import (
|
||||
get_active_management_account,
|
||||
get_active_management_account_async,
|
||||
has_management_access,
|
||||
locked_management_response,
|
||||
management_account_is_accepted,
|
||||
render_management_page_async,
|
||||
)
|
||||
from .path_params import ManagementSlugPath
|
||||
@@ -33,16 +39,82 @@ router.include_router(management_passwords.router)
|
||||
|
||||
|
||||
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
|
||||
def manage_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
|
||||
def manage_dashboard(
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
activated: bool = False,
|
||||
) -> str:
|
||||
account = get_active_management_account(store, management_slug)
|
||||
csrf_token = dashboard_csrf_token(management_slug, account)
|
||||
if not management_account_is_accepted(account):
|
||||
return _render_pending_management_page(settings, management_slug, account, csrf_token)
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return _render_management_unlock_page(management_slug, account, csrf_token)
|
||||
return _render_management_page(service, settings, management_slug, account, csrf_token)
|
||||
message = "Hostname registered. Add the update URL to your router." if activated else None
|
||||
return _render_management_page(service, settings, management_slug, account, csrf_token, message=message, request_ip=client_ip(request, settings))
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/activate", include_in_schema=False)
|
||||
async def activate_dashboard(
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
myip: str | None = None,
|
||||
ipaddr: str | None = None,
|
||||
myipv6: str | None = None,
|
||||
ip6addr: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
hostname = str(account["hostname"])
|
||||
if not management_account_is_accepted(account):
|
||||
try:
|
||||
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
|
||||
except BadIpError:
|
||||
return HTMLResponse(
|
||||
_render_pending_management_page(
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
"Registration needs a public source IP. Try again from your home connection or use the API with myip.",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
except DnsProviderError:
|
||||
return HTMLResponse(
|
||||
_render_pending_management_page(
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
"DNS registration failed. The hostname was not registered.",
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
account = await run_in_threadpool(store.activate_account, hostname)
|
||||
if not account:
|
||||
return _not_found()
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_HOSTNAME_ACTIVATED,
|
||||
"hostname registered",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return RedirectResponse(f"/m/{management_slug}?activated=1", status_code=303)
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def rotate_magic_credentials(
|
||||
async def rotate_dashboard_secrets(
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
@@ -50,12 +122,18 @@ async def rotate_magic_credentials(
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not management_account_is_accepted(account):
|
||||
return HTMLResponse(
|
||||
_render_pending_management_page(settings, management_slug, account, dashboard_csrf_token(management_slug, account), "Register this hostname first."),
|
||||
status_code=400,
|
||||
)
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
hostname = str(account["hostname"])
|
||||
action = first_form_value(form, "action")
|
||||
request_ip = client_ip(request, settings)
|
||||
if action == "update":
|
||||
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
|
||||
if not rotated:
|
||||
@@ -67,7 +145,7 @@ async def rotate_magic_credentials(
|
||||
None,
|
||||
DASHBOARD_UPDATE_URL_ROTATED,
|
||||
"router update URL rotated",
|
||||
client_ip(request, settings),
|
||||
request_ip,
|
||||
)
|
||||
return await render_management_page_async(
|
||||
service,
|
||||
@@ -76,35 +154,13 @@ async def rotate_magic_credentials(
|
||||
rotated,
|
||||
dashboard_csrf_token(management_slug, rotated),
|
||||
message="Update URL changed. Update your router now.",
|
||||
)
|
||||
if action == "password":
|
||||
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
||||
if not rotated:
|
||||
return _not_found()
|
||||
rotated["management_slug"] = management_slug
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
|
||||
"router credentials rotated",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
rotated,
|
||||
dashboard_csrf_token(management_slug, rotated),
|
||||
message="Router password changed. Copy it now.",
|
||||
created_account=rotated,
|
||||
request_ip=request_ip,
|
||||
)
|
||||
raise _bad_rotation_action()
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/delete", include_in_schema=False)
|
||||
async def delete_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
|
||||
async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||
@@ -4,9 +4,9 @@ from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
||||
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
|
||||
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.templates_management import _render_management_unlock_page
|
||||
from .management_shared import (
|
||||
@@ -24,7 +24,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
|
||||
async def unlock_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
|
||||
async def unlock_dashboard(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not account.get("dashboard_password_hash"):
|
||||
return RedirectResponse(f"/m/{management_slug}", status_code=303)
|
||||
@@ -70,7 +70,14 @@ async def set_dashboard_password(
|
||||
first_form_value(form, "current_dashboard_password"),
|
||||
)
|
||||
if not is_current:
|
||||
return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.")
|
||||
return await management_error_response_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
"Current password is incorrect.",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
password = first_form_value(form, "dashboard_password")
|
||||
confirm = first_form_value(form, "dashboard_password_confirm")
|
||||
if len(password) < 8:
|
||||
@@ -81,6 +88,7 @@ async def set_dashboard_password(
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Password must be at least 8 characters.",
|
||||
request_ip=client_ip(request, settings),
|
||||
)
|
||||
return HTMLResponse(page, status_code=400)
|
||||
if password != confirm:
|
||||
@@ -91,6 +99,7 @@ async def set_dashboard_password(
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="The passwords do not match.",
|
||||
request_ip=client_ip(request, settings),
|
||||
)
|
||||
return HTMLResponse(page, status_code=400)
|
||||
had_password = bool(account.get("dashboard_password_hash"))
|
||||
@@ -131,7 +140,14 @@ async def remove_dashboard_password(
|
||||
first_form_value(form, "current_dashboard_password"),
|
||||
)
|
||||
if not is_current:
|
||||
return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.")
|
||||
return await management_error_response_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
"Current password is incorrect.",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
await run_in_threadpool(store.clear_dashboard_password, hostname)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
@@ -4,16 +4,16 @@ from fastapi import HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..ddns_security import (
|
||||
from ..security import (
|
||||
DASHBOARD_UNLOCK_COOKIE,
|
||||
dashboard_cookie_signing_secret,
|
||||
dashboard_csrf_token,
|
||||
sign_dashboard_unlock_cookie,
|
||||
verify_dashboard_unlock_cookie,
|
||||
)
|
||||
from ..ddns_service import DdnsService
|
||||
from ..ddns_store import DdnsStore
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
from ..store.types import AccountRow
|
||||
from ..web.templates_management import _render_management_page, _render_management_unlock_page
|
||||
|
||||
@@ -31,6 +31,10 @@ async def get_active_management_account_async(store: DdnsStore, management_slug:
|
||||
return await run_in_threadpool(get_active_management_account, store, management_slug)
|
||||
|
||||
|
||||
def management_account_is_accepted(account: ManagementAccount) -> bool:
|
||||
return bool(account.get("accepted_at"))
|
||||
|
||||
|
||||
def has_management_access(request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount) -> bool:
|
||||
password_hash = account.get("dashboard_password_hash")
|
||||
if not password_hash:
|
||||
@@ -54,9 +58,18 @@ def management_error_response(
|
||||
management_slug: str,
|
||||
account: ManagementAccount,
|
||||
message: str,
|
||||
request_ip: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
return HTMLResponse(
|
||||
_render_management_page(service, settings, management_slug, account, dashboard_csrf_token(management_slug, account), message=message),
|
||||
_render_management_page(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message=message,
|
||||
request_ip=request_ip,
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -67,8 +80,9 @@ async def management_error_response_async(
|
||||
management_slug: str,
|
||||
account: ManagementAccount,
|
||||
message: str,
|
||||
request_ip: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message)
|
||||
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message, request_ip)
|
||||
|
||||
|
||||
async def render_management_page_async(
|
||||
@@ -79,6 +93,7 @@ async def render_management_page_async(
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
created_account: dict[str, str] | None = None,
|
||||
request_ip: str | None = None,
|
||||
) -> str:
|
||||
return await run_in_threadpool(
|
||||
_render_management_page,
|
||||
@@ -89,6 +104,7 @@ async def render_management_page_async(
|
||||
csrf_token,
|
||||
message,
|
||||
created_account,
|
||||
request_ip,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,203 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .services.ddns import DdnsService
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str = Field(examples=["not found"])
|
||||
|
||||
|
||||
class HostnameCreateResponse(BaseModel):
|
||||
hostname: str
|
||||
status: str = Field(description="Lifecycle state. Newly generated hostnames start as pending until registered.")
|
||||
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
|
||||
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
|
||||
registration_url: str = Field(description="Secret URL used to register the pending hostname after secrets are saved.")
|
||||
|
||||
|
||||
class HostnameStatusResponse(BaseModel):
|
||||
hostname: str
|
||||
status: str
|
||||
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
|
||||
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
updated_at: str | None = None
|
||||
history: list["HostnameUpdateEvent"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HostnameRotateUpdateResponse(BaseModel):
|
||||
hostname: str
|
||||
status: str
|
||||
update_url: str = Field(description="Secret replacement update URL for router DynDNS updates.")
|
||||
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
|
||||
|
||||
|
||||
class HostnameUpdateEvent(BaseModel):
|
||||
hostname: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
status: str
|
||||
detail: str
|
||||
created_at: str
|
||||
count: int = 1
|
||||
|
||||
|
||||
class AdminHostname(BaseModel):
|
||||
hostname: str
|
||||
status: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
updated_at: str | None = None
|
||||
created_at: str | None = None
|
||||
accepted_at: str | None = None
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class AdminRecord(BaseModel):
|
||||
hostname: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AdminUpdateEvent(BaseModel):
|
||||
hostname: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
status: str
|
||||
detail: str
|
||||
source_ip: str
|
||||
created_at: str
|
||||
count: int = 1
|
||||
|
||||
|
||||
class AdminCleanupRun(BaseModel):
|
||||
created_at: str
|
||||
unused_accounts_deleted: int
|
||||
|
||||
|
||||
class AdminScheduledTask(BaseModel):
|
||||
name: str
|
||||
interval_seconds: int
|
||||
next_run_at: str
|
||||
lease_owner: str | None = None
|
||||
lease_expires_at: str | None = None
|
||||
last_started_at: str | None = None
|
||||
last_finished_at: str | None = None
|
||||
last_status: str
|
||||
last_error: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AdminMetrics(BaseModel):
|
||||
values: dict[str, float]
|
||||
average_update_latency_ms: float
|
||||
|
||||
|
||||
class AdminTotals(BaseModel):
|
||||
hostnames: int
|
||||
records: int
|
||||
update_events: int
|
||||
cleanup_runs: int
|
||||
|
||||
|
||||
class AdminDashboardResponse(BaseModel):
|
||||
totals: AdminTotals
|
||||
metrics: AdminMetrics
|
||||
hostnames: list[AdminHostname]
|
||||
update_events: list[AdminUpdateEvent]
|
||||
cleanup_runs: list[AdminCleanupRun]
|
||||
scheduled_tasks: list[AdminScheduledTask]
|
||||
limit: int
|
||||
|
||||
|
||||
class AdminHostnamesResponse(BaseModel):
|
||||
items: list[AdminHostname]
|
||||
limit: int
|
||||
offset: int
|
||||
total: int
|
||||
|
||||
|
||||
class AdminRecordsResponse(BaseModel):
|
||||
items: list[AdminRecord]
|
||||
limit: int
|
||||
offset: int
|
||||
total: int
|
||||
|
||||
|
||||
class AdminEventsResponse(BaseModel):
|
||||
items: list[AdminUpdateEvent]
|
||||
limit: int
|
||||
offset: int
|
||||
next_before: str | None = None
|
||||
total: int
|
||||
|
||||
|
||||
def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameCreateResponse:
|
||||
return HostnameCreateResponse(
|
||||
hostname=account["hostname"],
|
||||
status="active" if account.get("accepted_at") else "pending",
|
||||
update_url=service.fritz_update_url(account),
|
||||
dashboard_url=service.dashboard_url(account),
|
||||
registration_url=service.registration_url(account),
|
||||
)
|
||||
|
||||
|
||||
def _hostname_status(
|
||||
service: DdnsService,
|
||||
account: dict[str, str | int | None],
|
||||
history: list[dict[str, str | int | None]] | None = None,
|
||||
) -> HostnameStatusResponse:
|
||||
account_for_url = {"update_slug": str(account["update_slug"])}
|
||||
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
|
||||
return HostnameStatusResponse(
|
||||
hostname=str(account["hostname"]),
|
||||
status="active" if account.get("accepted_at") else "pending",
|
||||
update_url=service.fritz_update_url(account_for_url),
|
||||
dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "",
|
||||
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
|
||||
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
|
||||
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
|
||||
history=[_hostname_update_event(event) for event in history or []],
|
||||
)
|
||||
|
||||
|
||||
def _hostname_rotate_update_response(service: DdnsService, account: dict[str, str | int | None], dashboard_secret: str) -> HostnameRotateUpdateResponse:
|
||||
return HostnameRotateUpdateResponse(
|
||||
hostname=str(account["hostname"]),
|
||||
status="active" if account.get("accepted_at") else "pending",
|
||||
update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
|
||||
dashboard_url=service.dashboard_url({"management_slug": dashboard_secret}),
|
||||
)
|
||||
|
||||
|
||||
def _hostname_update_event(row: dict[str, str | int | None]) -> HostnameUpdateEvent:
|
||||
return HostnameUpdateEvent(
|
||||
hostname=str(row["hostname"]),
|
||||
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
|
||||
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
|
||||
status=str(row["status"]),
|
||||
detail=str(row["detail"]),
|
||||
created_at=str(row["created_at"]),
|
||||
count=int(row.get("count") or 1),
|
||||
)
|
||||
|
||||
|
||||
def _admin_hostname(account: dict[str, str | int | None]) -> AdminHostname:
|
||||
return AdminHostname(
|
||||
hostname=str(account["hostname"]),
|
||||
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
|
||||
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
|
||||
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
|
||||
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
|
||||
created_at=str(account["created_at"]) if account.get("created_at") else None,
|
||||
accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None,
|
||||
disabled=bool(account.get("disabled")),
|
||||
)
|
||||
|
||||
|
||||
def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics:
|
||||
samples = metrics.get("update_latency_samples_total", 0)
|
||||
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
|
||||
return AdminMetrics(values=metrics, average_update_latency_ms=average_latency)
|
||||
@@ -10,7 +10,7 @@ from ipaddress import ip_address, ip_network
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .ddns_models import DdnsSettings
|
||||
from .settings import DdnsSettings
|
||||
|
||||
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
|
||||
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
|
||||
@@ -153,18 +153,11 @@ def client_ip(request: Request, settings: DdnsSettings | None = None) -> str:
|
||||
|
||||
|
||||
def is_rate_limited_path(path: str) -> bool:
|
||||
limited_paths = {
|
||||
"/magic",
|
||||
"/accounts",
|
||||
"/nic/update",
|
||||
}
|
||||
return path in limited_paths or path.startswith(("/u/", "/m/", "/api/v1/updates/")) or path.startswith(
|
||||
("/api/v1/hostnames/", "/api/v1/management/")
|
||||
)
|
||||
return path == "/" or path.startswith(("/m/", "/api/v1/hostnames", "/api/v1/router/"))
|
||||
|
||||
|
||||
def rate_limit_bucket(path: str) -> str:
|
||||
for prefix in ("/u/", "/m/", "/api/v1/updates/", "/api/v1/management/"):
|
||||
for prefix in ("/m/", "/api/v1/hostnames", "/api/v1/router/"):
|
||||
if path.startswith(prefix):
|
||||
return prefix.rstrip("/")
|
||||
return path
|
||||
@@ -12,11 +12,12 @@ from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
|
||||
from .ddns_models import DdnsSettings, UpdateResult
|
||||
from .ddns_security import client_ip
|
||||
from .ddns_store import DdnsStore
|
||||
from .event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED
|
||||
from ..dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
|
||||
from ..event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED
|
||||
from ..models import UpdateResult
|
||||
from ..security import client_ip
|
||||
from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,12 +40,12 @@ class DdnsService:
|
||||
self.store = store
|
||||
self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
|
||||
|
||||
def create_managed_account(self, username: str | None = None) -> dict[str, str]:
|
||||
def create_managed_account(self) -> dict[str, str]:
|
||||
if not self.settings.hostname_suffix:
|
||||
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
|
||||
for _ in range(5):
|
||||
try:
|
||||
return self.store.create_account(self.random_managed_hostname(), username)
|
||||
return self.store.create_account(self.random_managed_hostname(), accepted=False)
|
||||
except sqlite3.IntegrityError:
|
||||
continue
|
||||
raise HTTPException(status_code=500, detail="could not allocate hostname")
|
||||
@@ -63,6 +64,58 @@ class DdnsService:
|
||||
raise
|
||||
self.store.delete_account(hostname)
|
||||
|
||||
def publish_initial_dns(
|
||||
self,
|
||||
request: Request,
|
||||
hostname: str,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> None:
|
||||
if not dns_backend_configured(self.settings):
|
||||
return
|
||||
source_ip = client_ip(request, self.settings)
|
||||
source_fallback = source_ip if _is_global_ip(source_ip) else ""
|
||||
try:
|
||||
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
|
||||
source_fallback,
|
||||
myip,
|
||||
ipaddr,
|
||||
myipv6,
|
||||
ip6addr,
|
||||
)
|
||||
except BadIpError as exc:
|
||||
raise BadIpError("registration needs a public source IP or myip/myipv6 parameter") from exc
|
||||
lock = self._lock_for(hostname)
|
||||
with lock:
|
||||
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
|
||||
if planned.changed:
|
||||
try:
|
||||
publish_dns(planned, self.settings)
|
||||
except DnsReconciliationError as exc:
|
||||
LOGGER.warning(
|
||||
"Initial DNS reconciliation failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
|
||||
hostname,
|
||||
planned.update_ipv4,
|
||||
planned.update_ipv6,
|
||||
type(exc).__name__,
|
||||
)
|
||||
self.store.log_update_event(hostname, ipv4, ipv6, DNS_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
|
||||
raise
|
||||
except DnsProviderError as exc:
|
||||
LOGGER.warning(
|
||||
"Initial DNS publish failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
|
||||
hostname,
|
||||
planned.update_ipv4,
|
||||
planned.update_ipv6,
|
||||
type(exc).__name__,
|
||||
)
|
||||
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
|
||||
raise
|
||||
result = self.store.commit_update_result(planned)
|
||||
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "initial dns registered", source_ip)
|
||||
|
||||
def apply_update(
|
||||
self,
|
||||
request: Request,
|
||||
@@ -145,24 +198,18 @@ class DdnsService:
|
||||
def fritz_update_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
slug = urllib.parse.quote(account["update_slug"])
|
||||
return f"{base}/u/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
|
||||
return f"{base}/api/v1/router/update/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
|
||||
|
||||
def credentialed_update_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
hostname = urllib.parse.quote(account["hostname"])
|
||||
username = urllib.parse.quote(account["username"])
|
||||
password = urllib.parse.quote(account["password"])
|
||||
return (
|
||||
f"{base}/nic/update?hostname={hostname}"
|
||||
f"&username={username}&password={password}"
|
||||
"&myip=<ipaddr>&myipv6=<ip6addr>"
|
||||
)
|
||||
|
||||
def magic_management_url(self, account: dict[str, str]) -> str:
|
||||
def dashboard_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
slug = urllib.parse.quote(account["management_slug"])
|
||||
return f"{base}/m/{slug}"
|
||||
|
||||
def registration_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
slug = urllib.parse.quote(account["management_slug"])
|
||||
return f"{base}/api/v1/router/register/{slug}"
|
||||
|
||||
def random_managed_hostname(self) -> str:
|
||||
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
||||
if not suffix:
|
||||
@@ -213,6 +260,15 @@ def parse_update_ips(
|
||||
return ipv4, ipv6, update_ipv4, update_ipv6
|
||||
|
||||
|
||||
def _is_global_ip(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
return ipaddress.ip_address(value.strip()).is_global
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) -> str | None:
|
||||
selected = None
|
||||
for name, value in params:
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
@@ -30,6 +29,7 @@ class DdnsSettings(BaseModel):
|
||||
require_dns_provider: bool = Field(default=False)
|
||||
cloudflare_api_token: str = Field(default="")
|
||||
cloudflare_zone_id: str = Field(default="")
|
||||
cleanup_pending_account_hours: int = Field(default=24, ge=1, le=24 * 30)
|
||||
cleanup_unused_account_hours: int = Field(default=1080, ge=1, le=24 * 365)
|
||||
cleanup_event_history_hours: int = Field(default=24 * 180, ge=1, le=24 * 3650)
|
||||
cleanup_interval_seconds: int = Field(default=3600, ge=1, le=24 * 60 * 60)
|
||||
@@ -60,7 +60,6 @@ class DdnsSettings(BaseModel):
|
||||
if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path):
|
||||
raise ValueError("admin_path may only contain URL-safe path characters")
|
||||
reserved_paths = {
|
||||
"/accounts",
|
||||
"/admin.css",
|
||||
"/admin-tables.js",
|
||||
"/api",
|
||||
@@ -70,14 +69,11 @@ class DdnsSettings(BaseModel):
|
||||
"/favicon.ico",
|
||||
"/healthz",
|
||||
"/install.sh",
|
||||
"/ip",
|
||||
"/bootstrap-subset.css",
|
||||
"/logo.png",
|
||||
"/logo-nav.webp",
|
||||
"/logo.webp",
|
||||
"/m",
|
||||
"/magic",
|
||||
"/nic",
|
||||
"/openapi.json",
|
||||
"/privacy",
|
||||
"/redoc",
|
||||
@@ -87,7 +83,6 @@ class DdnsSettings(BaseModel):
|
||||
"/social-preview.png",
|
||||
"/social-preview.webp",
|
||||
"/theme.js",
|
||||
"/u",
|
||||
"/uninstall.sh",
|
||||
}
|
||||
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
|
||||
@@ -126,18 +121,3 @@ class DdnsSettings(BaseModel):
|
||||
from .config import settings_from_env
|
||||
|
||||
return settings_from_env()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UpdateResult:
|
||||
hostname: str
|
||||
ipv4: str | None
|
||||
ipv6: str | None
|
||||
changed: bool
|
||||
update_ipv4: bool = True
|
||||
update_ipv6: bool = True
|
||||
|
||||
@property
|
||||
def dyndns_response(self) -> str:
|
||||
ip = self.ipv4 or self.ipv6 or "0.0.0.0"
|
||||
return f"{'good' if self.changed else 'nochg'} {ip}"
|
||||
@@ -1,44 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..ddns_security import hash_lookup_token, hash_secret, verify_secret
|
||||
from ..security import hash_lookup_token, hash_secret, verify_secret
|
||||
from .types import AccountRow
|
||||
|
||||
|
||||
class AccountsMixin:
|
||||
def create_account(self, hostname: str, username: str | None = None) -> AccountRow:
|
||||
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
token = secrets.token_urlsafe(24)
|
||||
update_slug = secrets.token_urlsafe(32)
|
||||
management_slug = secrets.token_urlsafe(32)
|
||||
username = username or _default_username(hostname)
|
||||
token_hash = hash_secret(token)
|
||||
management_hash = hash_lookup_token(management_slug)
|
||||
accepted_at = now if accepted else None
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||
INSERT INTO accounts(hostname, update_slug, management_hash, created_at, accepted_at, disabled)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(hostname, username, token_hash, update_slug, management_hash, now),
|
||||
(hostname, update_slug, management_hash, now, accepted_at),
|
||||
)
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"username": username,
|
||||
"password": token,
|
||||
"update_slug": update_slug,
|
||||
"management_slug": management_slug,
|
||||
"accepted_at": accepted_at,
|
||||
}
|
||||
|
||||
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT accounts.hostname, accounts.username, accounts.update_slug,
|
||||
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||
SELECT accounts.hostname, accounts.update_slug,
|
||||
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||
FROM accounts
|
||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||
ORDER BY accounts.hostname
|
||||
@@ -57,8 +53,8 @@ class AccountsMixin:
|
||||
with self.connect() as conn:
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
|
||||
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
|
||||
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||
FROM accounts
|
||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||
WHERE accounts.hostname = ?
|
||||
@@ -71,7 +67,7 @@ class AccountsMixin:
|
||||
with self.connect() as conn:
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
|
||||
SELECT hostname, update_slug, accepted_at, disabled FROM accounts
|
||||
WHERE update_slug = ?
|
||||
""",
|
||||
(update_slug,),
|
||||
@@ -82,8 +78,8 @@ class AccountsMixin:
|
||||
with self.connect() as conn:
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
|
||||
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
|
||||
accounts.created_at, accounts.accepted_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||
FROM accounts
|
||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||
WHERE accounts.management_hash = ?
|
||||
@@ -92,20 +88,14 @@ class AccountsMixin:
|
||||
).fetchone()
|
||||
return dict(account) if account else None
|
||||
|
||||
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool:
|
||||
if not username or not token:
|
||||
return False
|
||||
def activate_account(self, hostname: str) -> AccountRow | None:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self.connect() as conn:
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT token_hash, disabled FROM accounts
|
||||
WHERE hostname = ? AND username = ?
|
||||
""",
|
||||
(hostname, username),
|
||||
).fetchone()
|
||||
if not account or account["disabled"]:
|
||||
return False
|
||||
return verify_secret(token, account["token_hash"])
|
||||
conn.execute(
|
||||
"UPDATE accounts SET accepted_at = COALESCE(accepted_at, ?) WHERE hostname = ?",
|
||||
(now, hostname),
|
||||
)
|
||||
return self.get_account_by_hostname(hostname)
|
||||
|
||||
def delete_account(self, hostname: str) -> None:
|
||||
with self.connect() as conn:
|
||||
@@ -128,16 +118,6 @@ class AccountsMixin:
|
||||
account["management_slug"] = management_slug
|
||||
return account
|
||||
|
||||
def rotate_password(self, hostname: str) -> AccountRow | None:
|
||||
token = secrets.token_urlsafe(24)
|
||||
token_hash = hash_secret(token)
|
||||
with self.connect() as conn:
|
||||
conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname))
|
||||
account = self.get_account_by_hostname(hostname)
|
||||
if account:
|
||||
account["password"] = token
|
||||
return account
|
||||
|
||||
def set_dashboard_password(self, hostname: str, password: str) -> AccountRow | None:
|
||||
password_hash = hash_secret(password)
|
||||
with self.connect() as conn:
|
||||
@@ -166,8 +146,3 @@ class AccountsMixin:
|
||||
if not account or account["disabled"] or not account["dashboard_password_hash"]:
|
||||
return False
|
||||
return verify_secret(password, account["dashboard_password_hash"])
|
||||
|
||||
|
||||
def _default_username(hostname: str) -> str:
|
||||
label = hostname.split(".", 1)[0]
|
||||
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
|
||||
@@ -118,20 +118,29 @@ class RateLimitCleanupMixin:
|
||||
self,
|
||||
unused_account_hours: int,
|
||||
event_history_hours: int = 24 * 180,
|
||||
pending_account_hours: int = 24,
|
||||
) -> dict[str, int]:
|
||||
self.flush_rate_limits()
|
||||
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
|
||||
pending_account_cutoff = (datetime.now(UTC) - timedelta(hours=pending_account_hours)).isoformat()
|
||||
event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
|
||||
with self.connect() as conn:
|
||||
account_cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM accounts
|
||||
WHERE created_at < ?
|
||||
WHERE (
|
||||
accepted_at IS NULL
|
||||
AND created_at < ?
|
||||
)
|
||||
OR (
|
||||
accepted_at IS NOT NULL
|
||||
AND created_at < ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
|
||||
)
|
||||
)
|
||||
""",
|
||||
(unused_account_cutoff,),
|
||||
(pending_account_cutoff, unused_account_cutoff),
|
||||
)
|
||||
rollup_cursor = conn.execute(
|
||||
"DELETE FROM update_event_rollups WHERE last_seen_at < ?",
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..ddns_models import UpdateResult
|
||||
from ..models import UpdateResult
|
||||
from .types import UpdateEventRow
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import secrets
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from ..ddns_security import hash_lookup_token
|
||||
from ..security import hash_lookup_token
|
||||
|
||||
BUSY_TIMEOUT_MS = 10_000
|
||||
CACHE_SIZE_KIB = 20_000
|
||||
MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
||||
WAL_AUTOCHECKPOINT_PAGES = 1_000
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
|
||||
def connect_database(database_path: Path) -> sqlite3.Connection:
|
||||
@@ -34,6 +34,7 @@ def configure_connection(conn: sqlite3.Connection) -> None:
|
||||
|
||||
def initialize_schema(conn: sqlite3.Connection) -> None:
|
||||
configure_connection(conn)
|
||||
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -143,12 +144,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
update_slug TEXT NOT NULL UNIQUE,
|
||||
management_hash TEXT UNIQUE,
|
||||
dashboard_password_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
disabled INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
@@ -156,6 +156,9 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
||||
_ensure_column(conn, "accounts", "update_slug", "TEXT")
|
||||
_ensure_column(conn, "accounts", "management_hash", "TEXT")
|
||||
_ensure_column(conn, "accounts", "dashboard_password_hash", "TEXT")
|
||||
_ensure_column(conn, "accounts", "accepted_at", "TEXT")
|
||||
if schema_version < 2:
|
||||
conn.execute("UPDATE accounts SET accepted_at = created_at WHERE accepted_at IS NULL")
|
||||
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
|
||||
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
|
||||
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
|
||||
@@ -163,11 +166,12 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
||||
"UPDATE accounts SET management_hash = ? WHERE hostname = ?",
|
||||
(hash_lookup_token(secrets.token_urlsafe(32)), row[0]),
|
||||
)
|
||||
_migrate_accounts_schema(conn)
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_accepted_at ON accounts(accepted_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_hostname_username ON accounts(hostname, username)")
|
||||
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
|
||||
if schema_version < SCHEMA_VERSION:
|
||||
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
conn.execute("PRAGMA optimize")
|
||||
|
||||
@@ -178,6 +182,39 @@ def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
|
||||
def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(accounts)")}
|
||||
if "username" not in columns and "token_hash" not in columns:
|
||||
return
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_update_slug")
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_management_hash")
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_accepted_at")
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_created_at")
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_hostname_username")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE accounts_new (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
update_slug TEXT NOT NULL UNIQUE,
|
||||
management_hash TEXT UNIQUE,
|
||||
dashboard_password_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
disabled INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled)
|
||||
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
|
||||
FROM accounts
|
||||
"""
|
||||
)
|
||||
conn.execute("DROP TABLE accounts")
|
||||
conn.execute("ALTER TABLE accounts_new RENAME TO accounts")
|
||||
|
||||
|
||||
def _migrate_cleanup_runs_schema(conn: sqlite3.Connection) -> None:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
|
||||
if columns <= {"id", "created_at", "unused_accounts_deleted"}:
|
||||
@@ -5,17 +5,15 @@ from typing import TypedDict
|
||||
|
||||
class AccountRow(TypedDict, total=False):
|
||||
hostname: str
|
||||
username: str
|
||||
token_hash: str
|
||||
update_slug: str
|
||||
management_slug: str
|
||||
dashboard_password_hash: str | None
|
||||
created_at: str
|
||||
accepted_at: str | None
|
||||
disabled: int
|
||||
ipv4: str | None
|
||||
ipv6: str | None
|
||||
updated_at: str | None
|
||||
password: str
|
||||
|
||||
|
||||
class UpdateEventRow(TypedDict, total=False):
|
||||
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address
|
||||
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .icons import _svg_icon
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"admin_hostname_deleted": "hostname deleted by admin",
|
||||
"admin_management_url_rotated": "dashboard link rotated by admin",
|
||||
"admin_update_url_rotated": "update URL rotated by admin",
|
||||
"dashboard_hostname_activated": "hostname registered",
|
||||
"dashboard_hostname_deleted": "hostname deleted",
|
||||
"dashboard_password_changed": "dashboard password changed",
|
||||
"dashboard_password_enabled": "dashboard password enabled",
|
||||
"dashboard_password_removed": "dashboard password removed",
|
||||
"dashboard_update_url_rotated": "update URL rotated",
|
||||
"dns_delete_failed": "DNS delete failed",
|
||||
"dns_failed": "DNS update failed",
|
||||
"dns_reconcile_failed": "DNS reconciliation failed",
|
||||
"unchanged": "unchanged",
|
||||
"updated": "updated",
|
||||
}
|
||||
|
||||
|
||||
def _event_label(status: object) -> str:
|
||||
value = str(status or "-")
|
||||
return _EVENT_LABELS.get(value, value.replace("_", " "))
|
||||
|
||||
|
||||
def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = escape(value)
|
||||
badge = ""
|
||||
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
|
||||
if sensitivity:
|
||||
safe_sensitivity = escape(sensitivity)
|
||||
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
|
||||
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
|
||||
value_field = (
|
||||
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
|
||||
if is_secret
|
||||
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
|
||||
)
|
||||
reveal_button = (
|
||||
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>'
|
||||
if is_secret
|
||||
else ""
|
||||
)
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="input-group copy-field">
|
||||
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
|
||||
{value_field}
|
||||
{reveal_button}
|
||||
<button type="button" class="btn btn-outline-secondary copy-button" data-copy="{safe_value}" aria-label="Copy {safe_label}">
|
||||
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = attr(value)
|
||||
safe_style = attr(style)
|
||||
return trusted(
|
||||
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
|
||||
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
|
||||
|
||||
def _copy_icon_button(label: str, value: str) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = attr(value)
|
||||
return trusted(
|
||||
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
|
||||
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
|
||||
|
||||
def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
buttons = []
|
||||
for item_label, item_value in items:
|
||||
buttons.append(
|
||||
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
|
||||
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
return trusted(
|
||||
f"""
|
||||
<details class="copy-menu">
|
||||
<summary aria-label="{safe_label}" title="{safe_label}">
|
||||
{_svg_icon("more", "more-icon")}
|
||||
</summary>
|
||||
<div class="copy-menu-popover" role="menu">
|
||||
{''.join(buttons)}
|
||||
</div>
|
||||
</details>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None) -> SafeHtml:
|
||||
safe_class = f" {attr(class_name)}" if class_name else ""
|
||||
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
|
||||
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="terminal-card code-window{safe_class}">
|
||||
{_copy_icon_button(copy_label, copy_value)}
|
||||
{code_lines}
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _code_copy_value(commands: str) -> str:
|
||||
lines = commands.splitlines()
|
||||
prompt_lines = [line[2:] for line in lines if line.startswith("$ ")]
|
||||
if prompt_lines:
|
||||
return "\n".join(prompt_lines)
|
||||
return "\n".join(line[2:] if line.startswith("$ ") else line for line in lines)
|
||||
|
||||
|
||||
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
|
||||
ipv4 = "203.0.113.10"
|
||||
ipv6 = "2001:db8::10"
|
||||
if not request_ip:
|
||||
return ipv4, ipv6
|
||||
try:
|
||||
parsed = ip_address(request_ip.strip())
|
||||
except ValueError:
|
||||
return ipv4, ipv6
|
||||
if not parsed.is_global:
|
||||
return ipv4, ipv6
|
||||
if parsed.version == 4:
|
||||
return str(parsed), ipv6
|
||||
return ipv4, str(parsed)
|
||||
|
||||
|
||||
def _manual_update_commands(update_url: str, request_ip: str | None = None) -> str:
|
||||
update_base = update_url.split("?", 1)[0]
|
||||
ipv4, ipv6 = _example_update_ips(request_ip)
|
||||
return "\n".join(
|
||||
[
|
||||
f"curl -sS '{update_base}?myip={ipv4}'",
|
||||
f"curl -sS '{update_base}?myipv6={ipv6}'",
|
||||
f"curl -sS '{update_base}' # use request IP",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _manual_update_panel(commands: str) -> SafeHtml:
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div>
|
||||
<h3>Manual updates</h3>
|
||||
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
|
||||
</div>
|
||||
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _fritzbox_fields(
|
||||
update_url: str,
|
||||
hostname: str,
|
||||
management_url: str | None = None,
|
||||
) -> str:
|
||||
lines = [
|
||||
f"Update-URL: {update_url}",
|
||||
f"Domainnamen: {hostname}",
|
||||
f"CNAME target: {hostname}",
|
||||
]
|
||||
if management_url:
|
||||
lines.append(f"Private dashboard: {management_url}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
|
||||
secret_items = "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="alert alert-info my-3 py-2 small" role="note">
|
||||
<strong>Public:</strong> Hostname and CNAME target can be shared.
|
||||
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
|
||||
Secrets are hidden on screen. Copy still copies the real value.
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _one_time_secret_notice() -> SafeHtml:
|
||||
return trusted(
|
||||
"""
|
||||
<div class="alert alert-warning my-3 py-2 small" role="alert">
|
||||
Copy the private URLs now. Newly created or rotated secrets are shown only once.
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _message_band(message: str | None) -> SafeHtml:
|
||||
if not message:
|
||||
return trusted("")
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
|
||||
<div class="container">{escape(message)}</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
@@ -4,7 +4,7 @@ import urllib.parse
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..settings import DdnsSettings
|
||||
|
||||
MAX_FORM_FIELDS = 100
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .icons import _svg_icon
|
||||
|
||||
|
||||
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
|
||||
account_html = ""
|
||||
if label:
|
||||
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
|
||||
if links is None:
|
||||
links = trusted('<a class="nav-link px-0" href="/docs">API</a>')
|
||||
theme_toggle = f"""
|
||||
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
|
||||
{_svg_icon("moon", "theme-icon theme-icon-moon")}
|
||||
{_svg_icon("sun", "theme-icon theme-icon-sun")}
|
||||
</button>
|
||||
"""
|
||||
return trusted(
|
||||
f"""
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
|
||||
<div class="container">
|
||||
<a class="navbar-brand brand" href="/"><img src="/logo-nav.webp?v=3" alt="" width="28" height="28" decoding="async">KarlDNS</a>
|
||||
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
|
||||
{links}
|
||||
{account_html}
|
||||
{theme_toggle}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _site_footer() -> SafeHtml:
|
||||
return trusted(
|
||||
f"""
|
||||
<footer class="site-footer border-top">
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
|
||||
<nav class="footer-links" aria-label="Footer links">
|
||||
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
|
||||
{_svg_icon("github", "footer-icon")}
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a class="footer-link" href="https://karl.fail" rel="noopener">
|
||||
{_svg_icon("blog", "footer-icon")}
|
||||
<span>karl.fail</span>
|
||||
</a>
|
||||
<a class="footer-link" href="https://karlcom.de" rel="noopener">
|
||||
{_svg_icon("business", "footer-icon")}
|
||||
<span>karlcom.de</span>
|
||||
</a>
|
||||
<a class="footer-link" href="/docs">
|
||||
{_svg_icon("docs", "footer-icon")}
|
||||
<span>API</span>
|
||||
</a>
|
||||
<a class="footer-link" href="/privacy">
|
||||
{_svg_icon("privacy", "footer-icon")}
|
||||
<span>Legal & privacy</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _page(
|
||||
title: str,
|
||||
body: SafeHtml,
|
||||
extra_styles: SafeHtml | None = None,
|
||||
extra_scripts: SafeHtml | None = None,
|
||||
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
|
||||
preload_logo: bool = False,
|
||||
canonical_url: str | None = None,
|
||||
image_url: str | None = None,
|
||||
image_alt: str | None = None,
|
||||
structured_data: dict[str, object] | None = None,
|
||||
) -> str:
|
||||
if not isinstance(body, SafeHtml):
|
||||
raise TypeError("body must be a SafeHtml fragment built by a template helper")
|
||||
extra_styles_html = extra_styles or trusted("")
|
||||
extra_scripts_html = extra_scripts or trusted("")
|
||||
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
|
||||
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
|
||||
social_url = canonical_url or ""
|
||||
social_image = image_url or ""
|
||||
social_html = trusted("")
|
||||
if social_url:
|
||||
social_card = "summary_large_image" if social_image else "summary"
|
||||
social_image_alt = image_alt or title
|
||||
social_image_html = f"""
|
||||
<meta property="og:image" content="{attr(social_image)}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:alt" content="{attr(social_image_alt)}">
|
||||
<meta name="twitter:image" content="{attr(social_image)}">
|
||||
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
|
||||
""" if social_image else ""
|
||||
social_html = trusted(f"""
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{attr(title)}">
|
||||
<meta property="og:description" content="{attr(description)}">
|
||||
<meta property="og:url" content="{attr(social_url)}">
|
||||
{social_image_html}
|
||||
<meta name="twitter:card" content="{attr(social_card)}">
|
||||
<meta name="twitter:title" content="{attr(title)}">
|
||||
<meta name="twitter:description" content="{attr(description)}">
|
||||
""")
|
||||
structured_data_html = trusted("")
|
||||
if structured_data:
|
||||
structured_data_html = trusted(
|
||||
'<script type="application/ld+json">'
|
||||
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
|
||||
+ "</script>"
|
||||
)
|
||||
return f"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="description" content="{attr(description)}">
|
||||
{canonical_html}
|
||||
{social_html}
|
||||
<title>{escape(title)}</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
{preload_logo_html}
|
||||
<script src="/theme.js?v=2"></script>
|
||||
<script src="/clipboard.js?v=3" defer></script>
|
||||
{extra_scripts_html}
|
||||
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
||||
<link href="/app.css?v=12" rel="stylesheet">
|
||||
{extra_styles_html}
|
||||
{structured_data_html}
|
||||
</head>
|
||||
<body>{body}{_site_footer()}</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -1,16 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
|
||||
from ..web.styles import ADMIN_CSS, BASE_CSS
|
||||
|
||||
ASSET_DIR = Path(__file__).parents[1] / "assets"
|
||||
router = APIRouter(include_in_schema=False)
|
||||
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||
|
||||
THEME_JS = """
|
||||
(() => {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -201,123 +190,3 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
||||
render();
|
||||
});
|
||||
""".strip()
|
||||
|
||||
|
||||
def _minify_css(css: str) -> str:
|
||||
return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}")
|
||||
|
||||
|
||||
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
|
||||
MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
|
||||
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
def favicon() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/bootstrap-subset.css")
|
||||
def bootstrap_subset_css() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/favicon-16x16.png")
|
||||
def favicon_16_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/favicon-32x32.png")
|
||||
def favicon_32_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/apple-touch-icon.png")
|
||||
def apple_touch_icon_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo.png")
|
||||
def logo_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo.webp")
|
||||
def logo_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-nav.webp")
|
||||
def logo_nav_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-192.png")
|
||||
def logo_192_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-512.png")
|
||||
def logo_512_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/social-preview.png")
|
||||
def social_preview_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/social-preview.webp")
|
||||
def social_preview_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/site.webmanifest")
|
||||
def site_webmanifest() -> Response:
|
||||
return Response(
|
||||
"""
|
||||
{
|
||||
"name": "KarlDNS",
|
||||
"short_name": "KarlDNS",
|
||||
"description": "Private update links for routers.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
""".strip(),
|
||||
media_type="application/manifest+json",
|
||||
headers=ASSET_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
@router.get("/theme.js")
|
||||
def theme_js() -> Response:
|
||||
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/clipboard.js")
|
||||
def clipboard_js() -> Response:
|
||||
return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/admin-tables.js")
|
||||
def admin_tables_js() -> Response:
|
||||
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/app.css")
|
||||
def app_css() -> Response:
|
||||
return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/admin.css")
|
||||
def admin_css() -> Response:
|
||||
return Response(MINIFIED_ADMIN_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/install.sh")
|
||||
def install_sh() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/uninstall.sh")
|
||||
def uninstall_sh() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
|
||||
status_code=302,
|
||||
)
|
||||
@@ -8,11 +8,11 @@ from fastapi.responses import PlainTextResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket
|
||||
from ..ddns_store import DdnsStore
|
||||
from ..security import client_ip, is_rate_limited_path, rate_limit_bucket
|
||||
from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
|
||||
ACCESS_LOGGER = logging.getLogger("router_dyndns.access")
|
||||
ACCESS_LOGGER = logging.getLogger("karldns.access")
|
||||
|
||||
|
||||
class RequestBodyTooLargeError(Exception):
|
||||
@@ -98,45 +98,33 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
|
||||
|
||||
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
|
||||
admin_path = settings.admin_path.rstrip("/")
|
||||
admin_records = f"{admin_path}/records"
|
||||
admin_events = f"{admin_path}/events"
|
||||
legacy_admin_api = settings.admin_path == "/admin" and path in {"/records", "/events"}
|
||||
return path == admin_path or path.startswith(f"{admin_path}/") or path in {admin_records, admin_events} or legacy_admin_api
|
||||
return path == admin_path or path.startswith(f"{admin_path}/") or path.startswith("/api/v1/admin/")
|
||||
|
||||
|
||||
def is_secret_response(request: Request) -> bool:
|
||||
path = request.url.path
|
||||
settings = request.app.state.settings
|
||||
if is_admin_path(path, settings) or path.startswith(("/m/", "/u/")):
|
||||
if is_admin_path(path, settings) or path.startswith("/m/"):
|
||||
return True
|
||||
if path.startswith("/api/v1/hostnames/"):
|
||||
return request.method in {"POST", "DELETE"}
|
||||
return (request.method == "POST" and path in {"/magic", "/accounts"}) or path == "/nic/update"
|
||||
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
|
||||
return True
|
||||
if path.startswith("/api/v1/admin/"):
|
||||
return True
|
||||
return request.method == "POST" and path == "/"
|
||||
|
||||
|
||||
def is_noindex_response(request: Request) -> bool:
|
||||
path = request.url.path
|
||||
if request.method != "GET":
|
||||
return True
|
||||
if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}:
|
||||
if path in {"/openapi.json", "/healthz"}:
|
||||
return True
|
||||
if path.startswith(("/api/", "/m/", "/u/", "/nic/")):
|
||||
if path.startswith(("/api/", "/m/")):
|
||||
return True
|
||||
return is_admin_path(path, request.app.state.settings)
|
||||
|
||||
|
||||
def content_security_policy(path: str) -> str:
|
||||
if path in {"/docs", "/redoc", "/openapi.json"}:
|
||||
return (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: https://fastapi.tiangolo.com; "
|
||||
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
|
||||
"connect-src 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
|
||||
return (
|
||||
"default-src 'self'; "
|
||||
@@ -152,14 +140,12 @@ def content_security_policy(path: str) -> str:
|
||||
|
||||
def redacted_access_path(path: str) -> str:
|
||||
secret_prefixes = {
|
||||
"/u": "/u/<secret>",
|
||||
"/m": "/m/<secret>",
|
||||
"/api/v1/updates": "/api/v1/updates/<secret>",
|
||||
"/api/v1/management": "/api/v1/management/<secret>",
|
||||
"/api/v1/hostnames": "/api/v1/hostnames/<secret>",
|
||||
"/api/v1/router/register": "/api/v1/router/register/<secret>",
|
||||
"/api/v1/router/update": "/api/v1/router/update/<secret>",
|
||||
}
|
||||
for prefix, replacement in secret_prefixes.items():
|
||||
if path == prefix or path.startswith(f"{prefix}/"):
|
||||
return replacement
|
||||
if path == "/nic/update":
|
||||
return "/nic/update?<redacted>"
|
||||
return path
|
||||
@@ -163,11 +163,41 @@ code {
|
||||
|
||||
.theme-toggle {
|
||||
align-items: center;
|
||||
aspect-ratio: 1;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--rp-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--rp-surface-alt);
|
||||
color: var(--rp-ink);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--rp-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.theme-toggle::-moz-focus-inner {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -224,6 +254,10 @@ code {
|
||||
background: var(--rp-canvas);
|
||||
}
|
||||
|
||||
.section-compact {
|
||||
padding: 56px 0;
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
border-top: 0;
|
||||
}
|
||||
@@ -327,6 +361,94 @@ h3 {
|
||||
font: 400 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.hero-action-form {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.simple-steps {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 620px;
|
||||
margin: 28px auto 0;
|
||||
padding: 0;
|
||||
color: var(--rp-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.simple-steps li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.simple-steps strong {
|
||||
color: var(--rp-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.api-endpoints {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.api-endpoints .simple-steps {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
max-width: 760px;
|
||||
margin: 32px auto 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flow-steps li {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
color: var(--rp-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.flow-steps li + li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: -22px;
|
||||
width: 22px;
|
||||
height: 1px;
|
||||
background: var(--rp-line);
|
||||
}
|
||||
|
||||
.flow-steps strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--rp-ink);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.flow-steps span {
|
||||
display: block;
|
||||
max-width: 140px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.installer-commands {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.installer-command {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--rp-surface);
|
||||
border: 1px solid var(--rp-line);
|
||||
@@ -582,6 +704,7 @@ select:focus-visible {
|
||||
color: var(--rp-terminal-ink);
|
||||
background: var(--rp-terminal);
|
||||
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.code-window {
|
||||
@@ -752,6 +875,10 @@ select:focus-visible {
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.section-compact {
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.hero-band {
|
||||
min-height: auto;
|
||||
}
|
||||
@@ -761,6 +888,17 @@ select:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.flow-steps {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.flow-steps li + li::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.container,
|
||||
.container.narrow,
|
||||
@@ -797,10 +935,26 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
max-width: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
max-width: 280px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.flow-steps li + li::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flow-steps span {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.hero-band .btn,
|
||||
.hero-action-form,
|
||||
.card button,
|
||||
.danger-section form,
|
||||
.danger-section button {
|
||||
@@ -835,6 +989,13 @@ select:focus-visible {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.copy-field code.form-control {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.copy-field .btn {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
@@ -2,11 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..ddns_security import admin_csrf_token
|
||||
from ..ddns_service import DdnsService
|
||||
from ..security import admin_csrf_token
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .templates import _page, _top_nav
|
||||
from .layout import _page, _top_nav
|
||||
from .templates_admin_tables import (
|
||||
_account_row,
|
||||
_admin_table_pagination,
|
||||
@@ -45,8 +45,6 @@ def _render_admin_page(
|
||||
csrf = attr(admin_csrf_token(settings))
|
||||
admin_path = settings.admin_path
|
||||
safe_admin_path = attr(admin_path)
|
||||
safe_records_path = attr(f"{admin_path}/records")
|
||||
safe_events_path = attr(f"{admin_path}/events")
|
||||
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
||||
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
|
||||
"""
|
||||
@@ -63,7 +61,7 @@ def _render_admin_page(
|
||||
"KarlDNS Admin",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
{_top_nav("Operator", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
|
||||
{_top_nav("Operator")}
|
||||
<section class="section admin-heading">
|
||||
<div class="container">
|
||||
<p class="eyebrow">KarlDNS</p>
|
||||
@@ -102,7 +100,7 @@ def _render_admin_page(
|
||||
<div class="table-responsive border rounded admin-table-scroll">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr><th scope="col">Hostname</th><th scope="col">User</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
|
||||
<tr><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
@@ -1,29 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .components import _event_label
|
||||
from .html_utils import attr, escape
|
||||
|
||||
|
||||
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
|
||||
hostname = escape(account["hostname"])
|
||||
username = escape(account["username"])
|
||||
status = "Active" if account.get("accepted_at") else "Pending"
|
||||
ipv4 = escape(account.get("ipv4") or "-")
|
||||
ipv6 = escape(account.get("ipv6") or "-")
|
||||
updated = escape(account.get("updated_at") or "Never")
|
||||
safe_admin_path = attr(admin_path)
|
||||
return f"""
|
||||
<tr>
|
||||
<td>{hostname}</td><td>{username}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
|
||||
<td>{hostname}</td><td>{escape(status)}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
|
||||
<td>
|
||||
<form method="post" action="{safe_admin_path}/accounts/rotate" class="d-flex gap-2 flex-wrap">
|
||||
<form method="post" action="{safe_admin_path}/hostnames/rotate" class="d-flex gap-2 flex-wrap">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname}">
|
||||
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button>
|
||||
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button>
|
||||
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Create a new router password" aria-label="New password for {hostname}">New password</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{safe_admin_path}/accounts/delete">
|
||||
<form method="post" action="{safe_admin_path}/hostnames/delete">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname}">
|
||||
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</button>
|
||||
@@ -69,7 +69,7 @@ def _admin_table_pagination() -> str:
|
||||
def _event_row(event: dict[str, str | int | None]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
hostname = escape(event.get("hostname") or "-")
|
||||
status = escape(event.get("status") or "-")
|
||||
status = escape(_event_label(event.get("status")))
|
||||
ipv4 = escape(event.get("ipv4") or "-")
|
||||
ipv6 = escape(event.get("ipv6") or "-")
|
||||
count = escape(event.get("count") or 1)
|
||||
@@ -1,20 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..ddns_service import DdnsService
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .templates import (
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .components import (
|
||||
_copy_menu,
|
||||
_copy_row,
|
||||
_event_label,
|
||||
_fritzbox_fields,
|
||||
_manual_update_commands,
|
||||
_manual_update_panel,
|
||||
_one_time_secret_notice,
|
||||
_page,
|
||||
_secret_notice,
|
||||
_top_nav,
|
||||
)
|
||||
from .templates_public_sections import _credentials_panel
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .templates_public_sections import _created_hostname_panel
|
||||
|
||||
|
||||
def _render_management_page(
|
||||
@@ -25,28 +24,26 @@ def _render_management_page(
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
created_account: dict[str, str] | None = None,
|
||||
request_ip: str | None = None,
|
||||
) -> str:
|
||||
account_for_url = {
|
||||
"hostname": str(account["hostname"]),
|
||||
"username": str(account["username"]),
|
||||
"password": "",
|
||||
"update_slug": str(account["update_slug"]),
|
||||
"management_slug": management_slug,
|
||||
}
|
||||
update_url = service.fritz_update_url(account_for_url)
|
||||
hostname = escape(account["hostname"])
|
||||
username = escape(account["username"])
|
||||
ipv4 = escape(account.get("ipv4") or "-")
|
||||
ipv6 = escape(account.get("ipv6") or "-")
|
||||
updated = escape(account.get("updated_at") or "Never")
|
||||
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated")
|
||||
fritzbox_fields = _fritzbox_fields(update_url, hostname)
|
||||
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
|
||||
manual_commands = _manual_update_commands(update_url)
|
||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
||||
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)) or """
|
||||
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
|
||||
"""
|
||||
message_html = _management_message(message)
|
||||
rotated_credentials = _credentials_panel(service, created_account)
|
||||
rotated_links = _created_hostname_panel(service, created_account, request_ip)
|
||||
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
|
||||
return _page(
|
||||
"KarlDNS management",
|
||||
@@ -62,15 +59,14 @@ def _render_management_page(
|
||||
</div>
|
||||
</section>
|
||||
{message_html}
|
||||
{rotated_credentials}
|
||||
{rotated_links}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-6">
|
||||
<p class="eyebrow">Router settings</p>
|
||||
<h2>Router update settings</h2>
|
||||
<p class="section-copy">Use the Update-URL if your router has one URL field. Username and password are only for routers that require them. Optional: point another DNS name at this hostname with a CNAME.</p>
|
||||
{_one_time_secret_notice()}
|
||||
<p class="section-copy">Use this Update-URL in your router or script. Optional: point another DNS name at this hostname with a CNAME.</p>
|
||||
{_secret_notice()}
|
||||
<div class="copy-actions mt-3">
|
||||
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])}
|
||||
@@ -80,16 +76,14 @@ def _render_management_page(
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
{_copy_row("Benutzername (optional):", username, "Public")}
|
||||
{_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
|
||||
</div>
|
||||
</div>
|
||||
{_manual_update_panel(manual_commands)}
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div>
|
||||
<p class="eyebrow">Actions</p>
|
||||
<h3>Replace router secrets</h3>
|
||||
<p class="section-copy">Create a new update URL or router password. Update your router after changing either one.</p>
|
||||
<h3>Replace router update URL</h3>
|
||||
<p class="section-copy">Create a new update URL. Update your router after changing it.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
||||
@@ -97,11 +91,6 @@ def _render_management_page(
|
||||
<input type="hidden" name="action" value="update">
|
||||
<button class="btn btn-outline-primary rounded-pill" type="submit">New update URL</button>
|
||||
</form>
|
||||
<form method="post" action="/m/{attr(management_slug)}/rotate">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<input type="hidden" name="action" value="password">
|
||||
<button class="btn btn-outline-primary rounded-pill" type="submit">New password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,6 +148,44 @@ def _render_management_page(
|
||||
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
|
||||
)
|
||||
|
||||
def _render_pending_management_page(
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: dict[str, str | int | None],
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
) -> str:
|
||||
hostname = escape(account["hostname"])
|
||||
message_html = _management_message(message)
|
||||
cleanup_hours = settings.cleanup_pending_account_hours
|
||||
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
|
||||
return _page(
|
||||
"Register hostname",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
{_top_nav()}
|
||||
<section class="hero-band compact">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<p class="eyebrow">Pending setup</p>
|
||||
<h1>{hostname}</h1>
|
||||
<p class="lead">Register this hostname after you have copied the private values. Unregistered setups are cleaned up after {escape(cleanup_text)}.</p>
|
||||
<form method="post" action="/m/{attr(management_slug)}/activate">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{message_html}
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow text-center">
|
||||
<p class="section-copy mx-auto">Lost the first page? Create a new hostname. Private URLs are shown only once.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
)
|
||||
|
||||
|
||||
def _management_message(message: str | None) -> SafeHtml:
|
||||
if not message:
|
||||
return trusted("")
|
||||
@@ -273,7 +300,7 @@ def _render_management_unlock_page(
|
||||
|
||||
def _user_event_row(event: dict[str, str | int | None]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
status = escape(event.get("status") or "-")
|
||||
status = escape(_event_label(event.get("status")))
|
||||
ipv4 = escape(event.get("ipv4") or "-")
|
||||
ipv6 = escape(event.get("ipv6") or "-")
|
||||
count = escape(event.get("count") or 1)
|
||||
@@ -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/<secret>.</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/<secret>.</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/<secret>.</strong> Read hostname status and recent history.</li>
|
||||
<li><strong>POST /api/v1/hostnames/<secret>/rotate-update-url.</strong> Replace the private router update URL.</li>
|
||||
<li><strong>DELETE /api/v1/hostnames/<secret>.</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",
|
||||
)
|
||||
@@ -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>
|
||||
"""
|
||||
)
|
||||
@@ -39,11 +39,11 @@ dev = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
karldns = "router_dyndns.cli:main"
|
||||
karldns = "karldns.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["router_dyndns"]
|
||||
artifacts = ["router_dyndns/assets/*"]
|
||||
packages = ["karldns"]
|
||||
artifacts = ["karldns/assets/**/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
dnspython==2.8.0
|
||||
fastapi==0.136.1
|
||||
pydantic==2.13.4
|
||||
uvicorn[standard]==0.47.0
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Self-hosted DynDNS service for FRITZ!Box and compatible routers."""
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .schemas import (
|
||||
ErrorResponse,
|
||||
HostnameCredentials,
|
||||
MagicHostnameRequest,
|
||||
ManagedRecord,
|
||||
RouterUpdateResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ErrorResponse",
|
||||
"HostnameCredentials",
|
||||
"MagicHostnameRequest",
|
||||
"ManagedRecord",
|
||||
"RouterUpdateResponse",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .store import DdnsStore
|
||||
|
||||
__all__ = ["DdnsStore"]
|
||||
@@ -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 "")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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>
|
||||
"""
|
||||
)
|
||||
@@ -6,15 +6,18 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_OUTPUT = ROOT / "router_dyndns" / "assets" / "bootstrap-subset.css"
|
||||
DEFAULT_OUTPUT = ROOT / "karldns" / "assets" / "bootstrap-subset.css"
|
||||
SCAN_PATHS = [
|
||||
ROOT / "router_dyndns" / "web",
|
||||
ROOT / "router_dyndns" / "routers",
|
||||
ROOT / "karldns" / "web",
|
||||
ROOT / "karldns" / "routers",
|
||||
]
|
||||
|
||||
CSS = r"""
|
||||
:root{--bs-body-color:#000;--bs-body-bg:#fff;--bs-border-color:#e5e5e5;--bs-secondary-color:#737373}[data-bs-theme="dark"]{--bs-body-color:#f5f5f5;--bs-body-bg:#0f0f0f;--bs-border-color:rgba(255,255,255,.16);--bs-secondary-color:#a3a3a3}html{-webkit-text-size-adjust:100%}body{margin:0}button,input,select{margin:0;font:inherit}button,select{text-transform:none}button:not(:disabled),[type="button"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}a{color:inherit}img,svg{vertical-align:middle}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}pre{margin:0}.container{width:100%;margin-right:auto;margin-left:auto;padding-right:12px;padding-left:12px}.row{display:flex;flex-wrap:wrap;margin-right:calc(-.5 * var(--bs-gutter-x,0));margin-left:calc(-.5 * var(--bs-gutter-x,0));margin-top:calc(-1 * var(--bs-gutter-y,0))}.row>*{width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x,0) * .5);padding-left:calc(var(--bs-gutter-x,0) * .5);margin-top:var(--bs-gutter-y,0)}.col-6{flex:0 0 auto;width:50%}.col-12{flex:0 0 auto;width:100%}@media (min-width:768px){.col-md-6{flex:0 0 auto;width:50%}.align-items-md-center{align-items:center!important}.flex-md-row{flex-direction:row!important}}@media (min-width:992px){.col-lg-3{flex:0 0 auto;width:25%}.col-lg-5{flex:0 0 auto;width:41.666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.333333%}.flex-lg-row{flex-direction:row!important}}@media (min-width:1200px){.col-xl-3{flex:0 0 auto;width:25%}}.g-3{--bs-gutter-x:16px;--bs-gutter-y:16px}.g-4{--bs-gutter-x:24px;--bs-gutter-y:24px}.d-flex{display:flex!important}.d-grid{display:grid!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-wrap{flex-wrap:wrap!important}.flex-shrink-0{flex-shrink:0!important}.align-items-center{align-items:center!important}.align-items-start{align-items:flex-start!important}.align-items-stretch{align-items:stretch!important}.align-self-start{align-self:flex-start!important}.justify-content-between{justify-content:space-between!important}.justify-content-center{justify-content:center!important}.justify-content-end{justify-content:flex-end!important}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.gap-sm-3{gap:16px!important}}.gap-2{gap:8px!important}.gap-3{gap:16px!important}.gap-4{gap:24px!important}.m-0{margin:0!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.ms-auto{margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-3{margin-top:16px!important;margin-bottom:16px!important}.mt-1{margin-top:4px!important}.mt-2{margin-top:8px!important}.mt-3{margin-top:16px!important}.mt-4{margin-top:24px!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:4px!important}.mb-2{margin-bottom:8px!important}.mb-3{margin-bottom:16px!important}.mb-4{margin-bottom:24px!important}.p-0{padding:0!important}.p-4{padding:24px!important}.px-0{padding-right:0!important;padding-left:0!important}.px-2{padding-right:8px!important;padding-left:8px!important}.px-3{padding-right:16px!important;padding-left:16px!important}.py-2{padding-top:8px!important;padding-bottom:8px!important}.w-100{width:100%!important}.h-100{height:100%!important}.text-center{text-align:center!important}.text-start{text-align:left!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-secondary{color:var(--bs-secondary-color)!important}.text-danger{color:var(--rp-danger)!important}.small{font-size:.875em!important}.h5{font-size:1.25rem}.opacity-75{opacity:.75!important}.rounded{border-radius:12px!important}.rounded-0{border-radius:0!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:9999px!important}.border{border:1px solid var(--bs-border-color)!important}.border-top{border-top:1px solid var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-end-0{border-right:0!important}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:5px;padding-bottom:5px;margin-right:16px;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-text{padding-top:8px;padding-bottom:8px}.nav-link{display:block;padding:8px;text-decoration:none}.btn{--bs-btn-color:#000;--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-btn-color);--bs-btn-hover-bg:var(--bs-btn-bg);--bs-btn-hover-border-color:var(--bs-btn-border-color);--bs-btn-active-color:var(--bs-btn-hover-color);--bs-btn-active-bg:var(--bs-btn-hover-bg);--bs-btn-active-border-color:var(--bs-btn-hover-border-color);color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border:1px solid var(--bLine truncated
|
||||
""".strip()
|
||||
""".strip().replace(
|
||||
".col-lg-3{flex:0 0 auto;width:25%}",
|
||||
".col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.333333%}",
|
||||
)
|
||||
|
||||
SUPPORTED_DYNAMIC_CLASSES = {"active", "disabled", "page-item", "page-link"}
|
||||
|
||||
@@ -44,14 +47,18 @@ def parse_args() -> argparse.Namespace:
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
output = args.output if args.output.is_absolute() else ROOT / args.output
|
||||
app_styles = (ROOT / "router_dyndns" / "web" / "styles.py").read_text()
|
||||
app_styles = (ROOT / "karldns" / "web" / "styles.py").read_text()
|
||||
supported = _class_names_from_css(CSS) | _class_names_from_css(app_styles) | SUPPORTED_DYNAMIC_CLASSES
|
||||
unsupported = sorted(_class_names_from_templates() - supported)
|
||||
if unsupported:
|
||||
unsupported_text = "\n".join(f"- {class_name}" for class_name in unsupported)
|
||||
raise SystemExit(f"Unsupported Bootstrap-style classes in templates:\n{unsupported_text}")
|
||||
output.write_text(CSS + "\n")
|
||||
print(f"Wrote {output.relative_to(ROOT)} ({output.stat().st_size} bytes)")
|
||||
try:
|
||||
display_path = output.relative_to(ROOT)
|
||||
except ValueError:
|
||||
display_path = output
|
||||
print(f"Wrote {display_path} ({output.stat().st_size} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ KARLDNS_INSTALL_MODE="${KARLDNS_INSTALL_MODE:-auto}"
|
||||
KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL:-}"
|
||||
KARLDNS_HOSTNAME="${KARLDNS_HOSTNAME:-}"
|
||||
KARLDNS_MANAGEMENT_URL="${KARLDNS_MANAGEMENT_URL:-}"
|
||||
KARLDNS_ACTIVATION_URL="${KARLDNS_ACTIVATION_URL:-}"
|
||||
KARLDNS_RAW_BASE_URL="${KARLDNS_RAW_BASE_URL:-https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts}"
|
||||
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
|
||||
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
|
||||
KARLDNS_SKIP_CRON="${KARLDNS_SKIP_CRON:-0}"
|
||||
KARLDNS_SKIP_FIRST_UPDATE="${KARLDNS_SKIP_FIRST_UPDATE:-0}"
|
||||
KARLDNS_REUSED_CONFIG=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -28,6 +30,7 @@ Options:
|
||||
--update-url URL Existing private update URL
|
||||
--hostname NAME Hostname to show after install
|
||||
--dashboard-url URL Private dashboard URL to show after install
|
||||
--activation-url URL Private registration URL for pending hostnames
|
||||
--ipv4 VALUE auto, off, or a static IPv4. Default: auto
|
||||
--ipv6 VALUE auto, off, or a static IPv6. Default: auto
|
||||
--no-cron Install files only, do not configure cron
|
||||
@@ -49,6 +52,7 @@ while [ "$#" -gt 0 ]; do
|
||||
--update-url) KARLDNS_UPDATE_URL="${2:?missing value for --update-url}"; shift 2 ;;
|
||||
--hostname) KARLDNS_HOSTNAME="${2:?missing value for --hostname}"; shift 2 ;;
|
||||
--dashboard-url|--management-url) KARLDNS_MANAGEMENT_URL="${2:?missing value for --dashboard-url}"; shift 2 ;;
|
||||
--activation-url) KARLDNS_ACTIVATION_URL="${2:?missing value for --activation-url}"; shift 2 ;;
|
||||
--ipv4) KARLDNS_IPV4="${2:?missing value for --ipv4}"; shift 2 ;;
|
||||
--ipv6) KARLDNS_IPV6="${2:?missing value for --ipv6}"; shift 2 ;;
|
||||
--no-cron) KARLDNS_SKIP_CRON=1; shift ;;
|
||||
@@ -81,6 +85,7 @@ quote_sh() {
|
||||
download_updater() {
|
||||
target_path="$1"
|
||||
tmp_path="$(mktemp)"
|
||||
mkdir -p "$(dirname "$target_path")"
|
||||
if ! "$curl_bin" -fsS "$KARLDNS_RAW_BASE_URL/karldns-update.sh" -o "$tmp_path"; then
|
||||
rm -f "$tmp_path"
|
||||
exit 1
|
||||
@@ -89,6 +94,18 @@ download_updater() {
|
||||
mv "$tmp_path" "$target_path"
|
||||
}
|
||||
|
||||
load_existing_config() {
|
||||
if [ -n "$KARLDNS_UPDATE_URL" ]; then
|
||||
return
|
||||
fi
|
||||
config_file="$1"
|
||||
if [ -r "$config_file" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$config_file"
|
||||
KARLDNS_REUSED_CONFIG=1
|
||||
fi
|
||||
}
|
||||
|
||||
cron_schedule() {
|
||||
if [ "$KARLDNS_INTERVAL_MINUTES" = "60" ]; then
|
||||
printf '0 * * * *'
|
||||
@@ -113,6 +130,44 @@ update_crontab() {
|
||||
printf '%s\n' "$marker_end"
|
||||
}
|
||||
|
||||
install_system_cron() {
|
||||
schedule="$1"
|
||||
command="$2"
|
||||
if [ -d /etc/cron.d ]; then
|
||||
cron_file="/etc/cron.d/karldns-updater"
|
||||
cat > "$cron_file" <<EOF
|
||||
SHELL=/bin/sh
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
$schedule root $command
|
||||
EOF
|
||||
chmod 644 "$cron_file"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -d /etc/crontabs ]; then
|
||||
cron_file="/etc/crontabs/root"
|
||||
tmp_file="$(mktemp)"
|
||||
update_crontab "$cron_file" "$schedule" "$command" > "$tmp_file"
|
||||
cat "$tmp_file" > "$cron_file"
|
||||
rm -f "$tmp_file"
|
||||
chmod 600 "$cron_file" 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v crontab >/dev/null 2>&1; then
|
||||
cron_file="$(mktemp)"
|
||||
crontab -l > "$cron_file" 2>/dev/null || true
|
||||
update_crontab "$cron_file" "$schedule" "$command" > "$cron_file.new"
|
||||
mv "$cron_file.new" "$cron_file"
|
||||
crontab "$cron_file"
|
||||
rm -f "$cron_file"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "KarlDNS installer: cron is not available. Install cron or re-run with --no-cron." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
write_config() {
|
||||
config_file="$1"
|
||||
cache_file="$2"
|
||||
@@ -123,6 +178,7 @@ write_config() {
|
||||
printf 'KARLDNS_UPDATE_URL=%s\n' "$(quote_sh "$KARLDNS_UPDATE_URL")"
|
||||
printf 'KARLDNS_HOSTNAME=%s\n' "$(quote_sh "$KARLDNS_HOSTNAME")"
|
||||
printf 'KARLDNS_MANAGEMENT_URL=%s\n' "$(quote_sh "$KARLDNS_MANAGEMENT_URL")"
|
||||
printf 'KARLDNS_ACTIVATION_URL=%s\n' "$(quote_sh "$KARLDNS_ACTIVATION_URL")"
|
||||
printf 'KARLDNS_CACHE_FILE=%s\n' "$(quote_sh "$cache_file")"
|
||||
printf 'KARLDNS_LOCK_FILE=%s\n' "$(quote_sh "$lock_file")"
|
||||
printf 'KARLDNS_IPV4=%s\n' "$(quote_sh "$KARLDNS_IPV4")"
|
||||
@@ -161,7 +217,6 @@ install_system() {
|
||||
cache_dir="/var/cache/karldns"
|
||||
config_file="${config_dir}/updater.env"
|
||||
updater_path="/usr/local/bin/karldns-update"
|
||||
cron_file="/etc/cron.d/karldns-updater"
|
||||
mkdir -p "$config_dir" "$cache_dir"
|
||||
|
||||
download_updater "$updater_path"
|
||||
@@ -169,12 +224,7 @@ install_system() {
|
||||
write_config "$config_file" "$cache_dir/last-ip" "/run/karldns-updater.lock"
|
||||
|
||||
if [ "$KARLDNS_SKIP_CRON" = "0" ]; then
|
||||
cat > "$cron_file" <<EOF
|
||||
SHELL=/bin/sh
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
$(cron_schedule) root KARLDNS_CONFIG=$(quote_sh "$config_file") $(quote_sh "$updater_path") >/dev/null 2>&1
|
||||
EOF
|
||||
chmod 644 "$cron_file"
|
||||
install_system_cron "$(cron_schedule)" "KARLDNS_CONFIG=$(quote_sh "$config_file") $(quote_sh "$updater_path") >/dev/null 2>&1"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -206,12 +256,19 @@ if [ "$KARLDNS_INSTALL_MODE" = "system" ] && [ "$(id -u)" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
|
||||
load_existing_config /etc/karldns/updater.env
|
||||
else
|
||||
load_existing_config "${XDG_CONFIG_HOME:-$HOME/.config}/karldns/updater.env"
|
||||
fi
|
||||
|
||||
if [ -z "$KARLDNS_UPDATE_URL" ]; then
|
||||
echo "KarlDNS: creating a new hostname at $KARLDNS_BASE_URL ..."
|
||||
response="$("$curl_bin" -fsS --max-time 30 -H 'content-type: application/json' -d '{}' "$KARLDNS_BASE_URL/api/v1/hostnames/magic")"
|
||||
response="$("$curl_bin" -fsS --max-time 30 -X POST "$KARLDNS_BASE_URL/api/v1/hostnames")"
|
||||
KARLDNS_UPDATE_URL="$(printf '%s' "$response" | json_field update_url)"
|
||||
KARLDNS_HOSTNAME="$(printf '%s' "$response" | json_field hostname)"
|
||||
KARLDNS_MANAGEMENT_URL="$(printf '%s' "$response" | json_field management_url)"
|
||||
KARLDNS_MANAGEMENT_URL="$(printf '%s' "$response" | json_field dashboard_url)"
|
||||
KARLDNS_ACTIVATION_URL="$(printf '%s' "$response" | json_field registration_url)"
|
||||
fi
|
||||
|
||||
if [ -z "$KARLDNS_UPDATE_URL" ]; then
|
||||
@@ -222,6 +279,11 @@ case "$KARLDNS_UPDATE_URL" in
|
||||
*"<ipaddr>"*|*"<ip6addr>"*) KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL%%\?*}" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$KARLDNS_ACTIVATION_URL" ] && [ "$KARLDNS_REUSED_CONFIG" = "0" ]; then
|
||||
echo "KarlDNS: registering hostname ..."
|
||||
"$curl_bin" -fsS --max-time 30 -X POST "$KARLDNS_ACTIVATION_URL" >/dev/null
|
||||
fi
|
||||
|
||||
if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
|
||||
install_system
|
||||
else
|
||||
|
||||
@@ -13,7 +13,6 @@ KARLDNS_BASE_URL="${KARLDNS_BASE_URL:-https://karldns.de}"
|
||||
KARLDNS_LOCK_FILE="${KARLDNS_LOCK_FILE:-/tmp/karldns-updater.lock}"
|
||||
KARLDNS_CACHE_FILE="${KARLDNS_CACHE_FILE:-${XDG_CACHE_HOME:-$HOME/.cache}/karldns/last-ip}"
|
||||
KARLDNS_CURL="${KARLDNS_CURL:-curl}"
|
||||
KARLDNS_IP_URL="${KARLDNS_IP_URL:-$KARLDNS_BASE_URL/ip}"
|
||||
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
|
||||
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
|
||||
case "$KARLDNS_UPDATE_URL" in
|
||||
@@ -39,12 +38,12 @@ fi
|
||||
ipv4=""
|
||||
ipv6=""
|
||||
if [ "$KARLDNS_IPV4" = "auto" ]; then
|
||||
ipv4="$("$KARLDNS_CURL" -4 -fsS --max-time 15 "$KARLDNS_IP_URL" 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
ipv4=""
|
||||
elif [ "$KARLDNS_IPV4" != "off" ]; then
|
||||
ipv4="$KARLDNS_IPV4"
|
||||
fi
|
||||
if [ "$KARLDNS_IPV6" = "auto" ]; then
|
||||
ipv6="$("$KARLDNS_CURL" -6 -fsS --max-time 15 "$KARLDNS_IP_URL" 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
ipv6=""
|
||||
elif [ "$KARLDNS_IPV6" != "off" ]; then
|
||||
ipv6="$KARLDNS_IPV6"
|
||||
fi
|
||||
|
||||
@@ -48,6 +48,33 @@ remove_user() {
|
||||
fi
|
||||
}
|
||||
|
||||
remove_marked_crontab_file() {
|
||||
cron_file="$1"
|
||||
[ -f "$cron_file" ] || return 0
|
||||
tmp_file="$(mktemp)"
|
||||
awk '
|
||||
$0 == "# karldns-update start" {skip = 1; next}
|
||||
$0 == "# karldns-update end" {skip = 0; next}
|
||||
skip != 1 {print}
|
||||
' "$cron_file" > "$tmp_file"
|
||||
cat "$tmp_file" > "$cron_file"
|
||||
rm -f "$tmp_file"
|
||||
}
|
||||
|
||||
remove_marked_root_crontab() {
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
tmp_file="$(mktemp)"
|
||||
crontab -l 2>/dev/null | awk '
|
||||
$0 == "# karldns-update start" {skip = 1; next}
|
||||
$0 == "# karldns-update end" {skip = 0; next}
|
||||
skip != 1 {print}
|
||||
' > "$tmp_file" || true
|
||||
crontab "$tmp_file"
|
||||
rm -f "$tmp_file"
|
||||
}
|
||||
|
||||
remove_system() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "System uninstall needs root. Re-run with sudo or set --mode user." >&2
|
||||
@@ -55,6 +82,8 @@ remove_system() {
|
||||
fi
|
||||
|
||||
rm -f /etc/cron.d/karldns-updater /usr/local/bin/karldns-update
|
||||
remove_marked_crontab_file /etc/crontabs/root
|
||||
remove_marked_root_crontab
|
||||
if [ "$KARLDNS_DELETE_CONFIG" = "1" ]; then
|
||||
rm -rf /etc/karldns /var/cache/karldns
|
||||
fi
|
||||
|
||||
@@ -2,8 +2,8 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
from router_dyndns.ddns_security import admin_csrf_token
|
||||
from karldns import DdnsSettings, DdnsStore, make_app
|
||||
from karldns.security import admin_csrf_token
|
||||
|
||||
|
||||
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
|
||||
@@ -48,12 +48,12 @@ def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
|
||||
response = client.get("/ops", auth=("admin", "admin"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="/ops/records"' in response.text
|
||||
assert 'href="/ops/events"' in response.text
|
||||
assert 'href="/ops/records"' not in response.text
|
||||
assert 'href="/ops/events"' not in response.text
|
||||
assert 'action="/ops/cleanup"' in response.text
|
||||
assert 'action="/ops/accounts/rotate"' in response.text
|
||||
assert 'action="/ops/hostnames/rotate"' in response.text
|
||||
assert 'action="/admin/cleanup"' not in response.text
|
||||
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 200
|
||||
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 404
|
||||
assert client.get("/records", auth=("admin", "admin")).status_code == 404
|
||||
assert client.get("/admin", auth=("admin", "admin")).status_code == 404
|
||||
|
||||
@@ -70,8 +70,8 @@ def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
records = client.get("/records?limit=1&offset=1", auth=("admin", "admin")).json()
|
||||
events = client.get("/events?limit=1&offset=1", auth=("admin", "admin")).json()
|
||||
records = client.get("/api/v1/admin/records?limit=1&offset=1", auth=("admin", "admin")).json()
|
||||
events = client.get("/api/v1/admin/events?limit=1&offset=1", auth=("admin", "admin")).json()
|
||||
|
||||
assert records["limit"] == 1
|
||||
assert records["offset"] == 1
|
||||
@@ -84,12 +84,48 @@ def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
|
||||
assert len(events["items"]) == 1
|
||||
|
||||
|
||||
def test_admin_api_exposes_dashboard_data_and_history(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "router")
|
||||
store.upsert(account["hostname"], "203.0.113.10", None)
|
||||
store.log_update_event(account["hostname"], "203.0.113.10", None, "updated", "ok", "198.51.100.1")
|
||||
store.increment_metric("update_requests_total")
|
||||
settings = DdnsSettings(database_path=database_path, admin_password="admin")
|
||||
app = make_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
assert client.get("/api/v1/admin/dashboard").status_code == 401
|
||||
|
||||
dashboard = client.get("/api/v1/admin/dashboard?limit=10", auth=("admin", "admin"))
|
||||
hostnames = client.get("/api/v1/admin/hostnames?limit=1&offset=0", auth=("admin", "admin"))
|
||||
records = client.get("/api/v1/admin/records?limit=1&offset=0", auth=("admin", "admin"))
|
||||
events = client.get("/api/v1/admin/events?limit=1&offset=0", auth=("admin", "admin"))
|
||||
|
||||
assert dashboard.status_code == 200
|
||||
assert dashboard.headers["cache-control"] == "no-store"
|
||||
dashboard_json = dashboard.json()
|
||||
assert dashboard_json["totals"]["hostnames"] == 1
|
||||
assert dashboard_json["totals"]["records"] == 1
|
||||
assert dashboard_json["totals"]["update_events"] == 1
|
||||
assert dashboard_json["metrics"]["values"]["update_requests_total"] == 1
|
||||
assert dashboard_json["hostnames"][0]["hostname"] == "home.example.net"
|
||||
assert dashboard_json["hostnames"][0]["status"] == "active"
|
||||
assert dashboard_json["hostnames"][0]["ipv4"] == "203.0.113.10"
|
||||
assert dashboard_json["update_events"][0]["status"] == "updated"
|
||||
assert dashboard_json["scheduled_tasks"][0]["name"] == "cleanup"
|
||||
|
||||
assert hostnames.json()["items"][0]["hostname"] == "home.example.net"
|
||||
assert records.json()["items"][0]["ipv4"] == "203.0.113.10"
|
||||
assert events.json()["items"][0]["hostname"] == "home.example.net"
|
||||
|
||||
|
||||
def test_admin_records_and_events_reject_excessive_offsets(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
records = client.get("/records?offset=10001", auth=("admin", "admin"))
|
||||
events = client.get("/events?offset=10001", auth=("admin", "admin"))
|
||||
records = client.get("/api/v1/admin/records?offset=10001", auth=("admin", "admin"))
|
||||
events = client.get("/api/v1/admin/events?offset=10001", auth=("admin", "admin"))
|
||||
|
||||
assert records.status_code == 422
|
||||
assert events.status_code == 422
|
||||
@@ -125,8 +161,8 @@ def test_update_events_support_keyset_pagination(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.get("/events?limit=1", auth=("admin", "admin")).json()
|
||||
second = client.get(f"/events?limit=1&before={first['next_before']}", auth=("admin", "admin")).json()
|
||||
first = client.get("/api/v1/admin/events?limit=1", auth=("admin", "admin")).json()
|
||||
second = client.get(f"/api/v1/admin/events?limit=1&before={first['next_before']}", auth=("admin", "admin")).json()
|
||||
|
||||
assert first["items"][0]["hostname"] == "raw-new.example.net"
|
||||
assert second["items"][0]["hostname"] == "raw-old.example.net"
|
||||
@@ -184,16 +220,16 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
|
||||
csrf = admin_csrf_token(settings)
|
||||
|
||||
update = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
"/admin/hostnames/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert "Update URL changed" in update.text
|
||||
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
|
||||
management = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
"/admin/hostnames/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
|
||||
)
|
||||
@@ -201,20 +237,10 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
|
||||
assert "Dashboard link changed" in management.text
|
||||
assert "/m/" in management.text
|
||||
|
||||
password = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
|
||||
)
|
||||
assert password.status_code == 200
|
||||
assert "Router password changed" in password.text
|
||||
assert "Full update URL:" in password.text
|
||||
assert "/nic/update?" in password.text
|
||||
assert "Kennwort (optional):" in password.text
|
||||
statuses = {event["status"] for event in store.list_update_events_for_hostname("home.example.net", 10)}
|
||||
assert "admin_update_url_rotated" in statuses
|
||||
assert "admin_management_url_rotated" in statuses
|
||||
assert "admin_router_credentials_rotated" in statuses
|
||||
assert "admin_router_credentials_rotated" not in statuses
|
||||
|
||||
|
||||
def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -> None:
|
||||
@@ -225,7 +251,7 @@ def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/admin/accounts/delete",
|
||||
"/admin/hostnames/delete",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "},
|
||||
follow_redirects=False,
|
||||
|
||||
@@ -1,81 +1,95 @@
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
|
||||
import karldns.services.ddns as ddns_module
|
||||
from karldns import DdnsSettings, DdnsStore, DnsProviderError, make_app
|
||||
|
||||
|
||||
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
def _path(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
return parsed.path
|
||||
|
||||
|
||||
def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
one_box_url = payload["credentialed_update_url"]
|
||||
parsed = urlparse(one_box_url)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
assert parsed.path == "/nic/update"
|
||||
assert params["hostname"] == [payload["hostname"]]
|
||||
assert params["username"] == [payload["username"]]
|
||||
assert params["password"] == [payload["password"]]
|
||||
assert params["myip"] == ["<ipaddr>"]
|
||||
assert params["myipv6"] == ["<ip6addr>"]
|
||||
|
||||
|
||||
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
docs = client.get("/docs")
|
||||
redoc = client.get("/redoc")
|
||||
schema = client.get("/openapi.json").json()
|
||||
|
||||
assert docs.status_code == 200
|
||||
assert redoc.status_code == 200
|
||||
assert docs.headers["x-robots-tag"] == "noindex, nofollow"
|
||||
assert redoc.headers["x-robots-tag"] == "noindex, nofollow"
|
||||
assert redoc.status_code == 404
|
||||
assert "Small API surface." in docs.text
|
||||
assert "POST /api/v1/hostnames" in docs.text
|
||||
assert "GET or POST /api/v1/router/register/<secret>" in docs.text
|
||||
assert "GET or POST /api/v1/router/update/<secret>" in docs.text
|
||||
assert "GET /api/v1/hostnames/<secret>" in docs.text
|
||||
assert "POST /api/v1/hostnames/<secret>/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/<secret>" not in docs.text
|
||||
assert "swagger-ui" not in docs.text.lower()
|
||||
assert "redoc" not in docs.text.lower()
|
||||
assert "x-robots-tag" not in docs.headers
|
||||
assert client.get("/openapi.json").headers["x-robots-tag"] == "noindex, nofollow"
|
||||
assert "unsafe-inline" in docs.headers["content-security-policy"]
|
||||
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
|
||||
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
|
||||
assert "/api/v1/hostnames/magic" in schema["paths"]
|
||||
assert "/api/v1/updates/{update_slug}" in schema["paths"]
|
||||
assert "/u/{update_slug}" in schema["paths"]
|
||||
assert "/nic/update" in schema["paths"]
|
||||
assert "/ip" in schema["paths"]
|
||||
assert "/healthz" in schema["paths"]
|
||||
assert "cdn.jsdelivr.net" not in docs.headers["content-security-policy"]
|
||||
assert "fonts.googleapis.com" not in docs.headers["content-security-policy"]
|
||||
|
||||
expected_paths = {
|
||||
"/api/v1/admin/dashboard",
|
||||
"/api/v1/admin/events",
|
||||
"/api/v1/admin/hostnames",
|
||||
"/api/v1/admin/records",
|
||||
"/api/v1/hostnames",
|
||||
"/api/v1/hostnames/{dashboard_secret}",
|
||||
"/api/v1/hostnames/{dashboard_secret}/rotate-update-url",
|
||||
"/api/v1/router/register/{dashboard_secret}",
|
||||
"/api/v1/router/update/{update_slug}",
|
||||
}
|
||||
assert set(schema["paths"]) == expected_paths
|
||||
assert "/" not in schema["paths"]
|
||||
assert "/admin" not in schema["paths"]
|
||||
assert "/healthz" not in schema["paths"]
|
||||
assert "/records" not in schema["paths"]
|
||||
assert "/events" not in schema["paths"]
|
||||
assert "/" not in schema["paths"]
|
||||
assert "/magic" not in schema["paths"]
|
||||
assert "/accounts" not in schema["paths"]
|
||||
assert "/m/{management_slug}" not in schema["paths"]
|
||||
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
||||
assert all(tag["name"] != "admin" for tag in schema["tags"])
|
||||
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
|
||||
assert "Dashboard passwords protect only the web dashboard" in management_tag["description"]
|
||||
management_get = schema["paths"]["/api/v1/management/{management_slug}"]["get"]
|
||||
management_delete = schema["paths"]["/api/v1/management/{management_slug}"]["delete"]
|
||||
assert "Dashboard passwords protect only the web dashboard" in management_get["description"]
|
||||
assert "Dashboard passwords protect only the web dashboard" in management_delete["description"]
|
||||
assert any(tag["name"] == "router" for tag in schema["tags"])
|
||||
assert any(tag["name"] == "admin" for tag in schema["tags"])
|
||||
|
||||
|
||||
def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
|
||||
def test_custom_api_docs_use_visitor_public_ip(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app, client=("8.8.4.4", 12345))
|
||||
|
||||
response = client.get("/docs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "myip=8.8.4.4" in response.text
|
||||
|
||||
|
||||
def test_openapi_hides_custom_operator_routes(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
@@ -85,78 +99,38 @@ def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
redoc = client.get("/redoc")
|
||||
schema = client.get("/openapi.json").json()
|
||||
|
||||
forbidden_paths = {
|
||||
"/ops",
|
||||
"/ops/accounts/delete",
|
||||
"/ops/accounts/rotate",
|
||||
"/ops/hostnames/delete",
|
||||
"/ops/hostnames/rotate",
|
||||
"/ops/cleanup",
|
||||
"/ops/events",
|
||||
"/ops/records",
|
||||
"/records",
|
||||
"/events",
|
||||
}
|
||||
assert redoc.status_code == 200
|
||||
assert client.get("/redoc").status_code == 404
|
||||
assert forbidden_paths.isdisjoint(schema["paths"])
|
||||
assert all("admin" not in tag["name"].lower() for tag in schema["tags"])
|
||||
assert "/api/v1/admin/dashboard" in schema["paths"]
|
||||
|
||||
|
||||
def test_public_redoc_only_exposes_api_and_router_endpoints(tmp_path: Path) -> None:
|
||||
def test_openapi_marks_generated_links_as_secret(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
schema = client.get("/openapi.json").json()
|
||||
created = schema["components"]["schemas"]["HostnameCreateResponse"]["properties"]
|
||||
status = schema["components"]["schemas"]["HostnameStatusResponse"]["properties"]
|
||||
|
||||
expected_public_paths = {
|
||||
"/api/v1/hostnames/magic",
|
||||
"/api/v1/management/{management_slug}",
|
||||
"/api/v1/updates/{update_slug}",
|
||||
"/healthz",
|
||||
"/ip",
|
||||
"/nic/update",
|
||||
"/u/{update_slug}",
|
||||
}
|
||||
assert set(schema["paths"]) == expected_public_paths
|
||||
for name in ["update_url", "dashboard_url", "registration_url"]:
|
||||
assert "Secret" in created[name]["description"]
|
||||
for name in ["update_url", "dashboard_url"]:
|
||||
assert "Secret" in status[name]["description"]
|
||||
|
||||
|
||||
def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
schema = client.get("/openapi.json").json()
|
||||
credentials = schema["components"]["schemas"]["HostnameCredentials"]["properties"]
|
||||
managed = schema["components"]["schemas"]["ManagedRecord"]["properties"]
|
||||
|
||||
for name in ["password", "update_url", "credentialed_update_url", "management_url"]:
|
||||
assert "Secret" in credentials[name]["description"]
|
||||
assert "Secret" in managed["update_url"]["description"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "payload"),
|
||||
[
|
||||
("/api/v1/hostnames/magic", {"username": "router", "unexpected": True}),
|
||||
],
|
||||
)
|
||||
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(path, json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"][0]["type"] == "extra_forbidden"
|
||||
|
||||
|
||||
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
|
||||
def test_hostname_api_lifecycle_and_history(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
@@ -167,38 +141,165 @@ def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
created = client.post("/api/v1/hostnames")
|
||||
|
||||
assert created.status_code == 201
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
assert created.headers["x-robots-tag"] == "noindex, nofollow"
|
||||
body = created.json()
|
||||
assert body["hostname"].endswith(".ddns.example.net")
|
||||
assert body["update_url"].startswith("http://ddns.example.net/u/")
|
||||
assert body["management_url"].startswith("http://ddns.example.net/m/")
|
||||
assert body["status"] == "pending"
|
||||
assert body["update_url"].startswith("http://ddns.example.net/api/v1/router/update/")
|
||||
assert body["dashboard_url"].startswith("http://ddns.example.net/m/")
|
||||
assert body["registration_url"].startswith("http://ddns.example.net/api/v1/router/register/")
|
||||
|
||||
management_slug = body["management_url"].rsplit("/", 1)[1]
|
||||
managed = client.get(f"/api/v1/management/{management_slug}")
|
||||
assert managed.status_code == 200
|
||||
assert managed.json()["hostname"] == body["hostname"]
|
||||
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
|
||||
update_path = _path(body["update_url"])
|
||||
register_path = _path(body["registration_url"])
|
||||
|
||||
deleted = client.delete(f"/api/v1/management/{management_slug}")
|
||||
pending = client.get(f"/api/v1/hostnames/{dashboard_secret}")
|
||||
assert pending.status_code == 200
|
||||
assert pending.json()["status"] == "pending"
|
||||
assert pending.json()["history"] == []
|
||||
assert client.get(update_path, params={"myip": "203.0.113.11"}).status_code == 404
|
||||
|
||||
registered = client.post(register_path, params={"myip": "203.0.113.10"})
|
||||
assert registered.status_code == 200
|
||||
assert registered.text == "good registered"
|
||||
|
||||
update = client.get(update_path, params={"myip": "203.0.113.11"})
|
||||
assert update.status_code == 200
|
||||
assert update.text == "good 203.0.113.11"
|
||||
|
||||
active = client.get(f"/api/v1/hostnames/{dashboard_secret}", params={"history_limit": 10})
|
||||
assert active.status_code == 200
|
||||
payload = active.json()
|
||||
assert payload["status"] == "active"
|
||||
assert payload["ipv4"] == "203.0.113.11"
|
||||
assert payload["history"][0]["hostname"] == body["hostname"]
|
||||
assert "source_ip" not in payload["history"][0]
|
||||
|
||||
deleted = client.delete(f"/api/v1/hostnames/{dashboard_secret}")
|
||||
assert deleted.status_code == 204
|
||||
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
|
||||
assert client.get(f"/api/v1/hostnames/{dashboard_secret}").status_code == 404
|
||||
|
||||
|
||||
def test_hostname_api_can_rotate_update_url(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
body = client.post("/api/v1/hostnames").json()
|
||||
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
|
||||
old_update_path = _path(body["update_url"])
|
||||
|
||||
pending_rotate = client.post(f"/api/v1/hostnames/{dashboard_secret}/rotate-update-url")
|
||||
registered = client.post(_path(body["registration_url"]), params={"myip": "203.0.113.10"})
|
||||
rotated = client.post(f"/api/v1/hostnames/{dashboard_secret}/rotate-update-url")
|
||||
new_update_path = _path(rotated.json()["update_url"])
|
||||
|
||||
assert pending_rotate.status_code == 400
|
||||
assert registered.status_code == 200
|
||||
assert rotated.status_code == 200
|
||||
assert rotated.json()["hostname"] == body["hostname"]
|
||||
assert rotated.json()["update_url"] != body["update_url"]
|
||||
assert client.get(old_update_path, params={"myip": "203.0.113.11"}).status_code == 404
|
||||
assert client.get(new_update_path, params={"myip": "203.0.113.12"}).status_code == 200
|
||||
|
||||
|
||||
def test_hostname_api_history_is_scoped_to_dashboard_secret(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
own = store.create_account("own.ddns.example.net", "own-router")
|
||||
other = store.create_account("other.ddns.example.net", "other-router")
|
||||
store.log_update_event("own.ddns.example.net", "203.0.113.21", None, "good", "updated", "198.51.100.10")
|
||||
store.log_update_event("other.ddns.example.net", "203.0.113.22", None, "good", "updated", "198.51.100.11")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/hostnames/{own['management_slug']}", params={"history_limit": 10})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
payload = response.json()
|
||||
assert len(payload["history"]) == 1
|
||||
assert payload["history"][0]["hostname"] == "own.ddns.example.net"
|
||||
assert "source_ip" not in payload["history"][0]
|
||||
assert "other.ddns.example.net" not in response.text
|
||||
assert client.get(f"/api/v1/hostnames/{other['management_slug']}", params={"history_limit": 101}).status_code == 422
|
||||
assert client.get(f"/api/v1/hostnames/{'a' * 32}").status_code == 404
|
||||
|
||||
|
||||
def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
body = client.post("/api/v1/hostnames").json()
|
||||
registered = client.post(_path(body["registration_url"]), params={"myip": "203.0.113.77"})
|
||||
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}")
|
||||
|
||||
assert registered.status_code == 200
|
||||
assert status.json()["status"] == "active"
|
||||
assert status.json()["ipv4"] == "203.0.113.77"
|
||||
assert len(published) == 1
|
||||
assert published[0].hostname == body["hostname"]
|
||||
assert published[0].ipv4 == "203.0.113.77"
|
||||
|
||||
|
||||
def test_registration_requires_ip_when_provider_source_ip_is_not_public(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: None)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
body = client.post("/api/v1/hostnames").json()
|
||||
registered = client.post(_path(body["registration_url"]))
|
||||
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}")
|
||||
|
||||
assert registered.status_code == 400
|
||||
assert registered.text.startswith("badip registration needs a public source IP")
|
||||
assert status.json()["status"] == "pending"
|
||||
|
||||
|
||||
def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
|
||||
assert response.text == "good 203.0.113.11"
|
||||
assert len(published) == 1
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
|
||||
|
||||
@@ -207,14 +308,14 @@ def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypa
|
||||
def fail_publish(result, settings):
|
||||
raise DnsProviderError("dns down")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", fail_publish)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
@@ -232,8 +333,8 @@ def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
first = client.post("/api/v1/hostnames")
|
||||
second = client.post("/api/v1/hostnames")
|
||||
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 429
|
||||
@@ -246,8 +347,8 @@ def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
|
||||
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
|
||||
assert response.text == "badip"
|
||||
assert store.list_records() == []
|
||||
@@ -3,27 +3,29 @@ import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.app as app_module
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
import karldns.app as app_module
|
||||
from karldns import DdnsSettings, DdnsStore, make_app
|
||||
from karldns.security import hash_lookup_token
|
||||
|
||||
|
||||
def test_rate_limit_reject_does_not_increment_bucket(tmp_path: Path) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
|
||||
assert store.allow_rate_limit("198.51.100.1:/magic", 1) is True
|
||||
assert store.allow_rate_limit("198.51.100.1:/magic", 1) is False
|
||||
assert store.allow_rate_limit("198.51.100.1:/", 1) is True
|
||||
assert store.allow_rate_limit("198.51.100.1:/", 1) is False
|
||||
store.flush_rate_limits()
|
||||
|
||||
window = int(time.time()) // 60
|
||||
with store.connect() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
|
||||
("198.51.100.1:/magic", window),
|
||||
("198.51.100.1:/", window),
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
@@ -161,6 +163,52 @@ def test_cleanup_records_only_unused_hostname_count(tmp_path: Path) -> None:
|
||||
assert set(store.list_cleanup_runs()[0]) == {"created_at", "unused_accounts_deleted"}
|
||||
|
||||
|
||||
def test_cleanup_removes_pending_generated_setups_after_cutoff(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
|
||||
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET created_at = ? WHERE hostname = ?",
|
||||
("2000-01-01T00:00:00+00:00", account["hostname"]),
|
||||
)
|
||||
|
||||
result = store.cleanup(unused_account_hours=45 * 24)
|
||||
|
||||
assert result["unused_accounts"] == 1
|
||||
assert store.get_account_by_slug(account["update_slug"]) is None
|
||||
|
||||
|
||||
def test_cleanup_keeps_recent_pending_setup_until_pending_cutoff(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
|
||||
|
||||
recent = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET created_at = ? WHERE hostname = ?",
|
||||
(recent, account["hostname"]),
|
||||
)
|
||||
|
||||
result = store.cleanup(unused_account_hours=1, pending_account_hours=24)
|
||||
|
||||
assert result["unused_accounts"] == 0
|
||||
assert store.get_account_by_slug(account["update_slug"]) is not None
|
||||
|
||||
|
||||
def test_pending_generated_setup_stays_pending_after_restart(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
|
||||
store.close()
|
||||
|
||||
restarted = DdnsStore(database_path)
|
||||
|
||||
assert restarted.get_account_by_slug(account["update_slug"])["accepted_at"] is None
|
||||
|
||||
|
||||
def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
|
||||
@@ -194,7 +242,7 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
assert cache_size == -20_000
|
||||
assert temp_store == 2
|
||||
assert mmap_size == 256 * 1024 * 1024
|
||||
assert user_version == 1
|
||||
assert user_version == 3
|
||||
assert {
|
||||
"idx_accounts_created_at",
|
||||
"idx_rate_limits_window",
|
||||
@@ -203,6 +251,58 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
}.issubset(index_names)
|
||||
|
||||
|
||||
def test_accounts_schema_migration_removes_legacy_router_credentials(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
with sqlite3.connect(database_path) as conn:
|
||||
conn.execute("PRAGMA user_version = 2")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE accounts (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
update_slug TEXT NOT NULL UNIQUE,
|
||||
management_hash TEXT UNIQUE,
|
||||
dashboard_password_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
disabled INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts(
|
||||
hostname, username, token_hash, update_slug, management_hash,
|
||||
dashboard_password_hash, created_at, accepted_at, disabled
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0)
|
||||
""",
|
||||
(
|
||||
"home.ddns.example.net",
|
||||
"router",
|
||||
"old-token-hash",
|
||||
"update-secret",
|
||||
hash_lookup_token("dashboard-secret"),
|
||||
"2026-05-21T00:00:00+00:00",
|
||||
"2026-05-21T00:00:00+00:00",
|
||||
),
|
||||
)
|
||||
|
||||
store = DdnsStore(database_path)
|
||||
account = store.get_account_by_slug("update-secret")
|
||||
|
||||
with store.connect() as conn:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(accounts)")}
|
||||
user_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
|
||||
assert user_version == 3
|
||||
assert "username" not in columns
|
||||
assert "token_hash" not in columns
|
||||
assert account
|
||||
assert account["hostname"] == "home.ddns.example.net"
|
||||
|
||||
|
||||
def test_rate_limit_flush_requeues_counts_after_database_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
|
||||
@@ -286,10 +386,10 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
|
||||
store = DdnsStore(database_path)
|
||||
store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
|
||||
|
||||
def fail_cleanup(unused_account_hours, event_history_hours):
|
||||
def fail_cleanup(unused_account_hours, event_history_hours, pending_account_hours):
|
||||
raise RuntimeError("cleanup exploded")
|
||||
|
||||
caplog.set_level(logging.ERROR, logger="router_dyndns.app")
|
||||
caplog.set_level(logging.ERROR, logger="karldns.app")
|
||||
monkeypatch.setattr(store, "cleanup", fail_cleanup)
|
||||
|
||||
asyncio.run(app_module._run_cleanup_if_due(store, settings))
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from router_dyndns.ddns import DdnsSettings, make_app
|
||||
from karldns import DdnsSettings, make_app
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_updater_strips_fritzbox_placeholders_and_caches_ip(tmp_path: Path) -> N
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
account = client.post("/api/v1/hostnames/magic", json={}).json()
|
||||
account = client.post("/api/v1/hostnames").json()
|
||||
update_url = account["update_url"]
|
||||
script = tmp_path / "curl"
|
||||
log_file = tmp_path / "curl.log"
|
||||
@@ -51,7 +51,6 @@ def test_updater_strips_fritzbox_placeholders_and_caches_ip(tmp_path: Path) -> N
|
||||
f"""#!/bin/sh
|
||||
printf '%s\\n' "$*" >> {log_file}
|
||||
case "$*" in
|
||||
*'/ip'*) printf '203.0.113.25\\n' ;;
|
||||
*'<ipaddr>'*|*'<ip6addr>'*) printf 'bad placeholder leaked\\n'; exit 1 ;;
|
||||
*'myip=203.0.113.25'*) printf 'good 203.0.113.25\\n' ;;
|
||||
*) printf 'bad request\\n'; exit 1 ;;
|
||||
@@ -67,9 +66,9 @@ esac
|
||||
env={
|
||||
"KARLDNS_UPDATE_URL": update_url,
|
||||
"KARLDNS_CURL": str(script),
|
||||
"KARLDNS_IP_URL": "https://karldns.de/ip",
|
||||
"KARLDNS_CACHE_FILE": str(cache_file),
|
||||
"KARLDNS_LOCK_FILE": str(lock_file),
|
||||
"KARLDNS_IPV4": "203.0.113.25",
|
||||
"KARLDNS_IPV6": "off",
|
||||
},
|
||||
)
|
||||
@@ -77,3 +76,174 @@ esac
|
||||
assert "good 203.0.113.25" in result.stdout
|
||||
assert cache_file.read_text(encoding="utf-8").strip() == "203.0.113.25|"
|
||||
assert "<ipaddr>" not in log_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_installer_registers_generated_hostname_before_use(tmp_path: Path) -> None:
|
||||
bin_dir = tmp_path / "bin"
|
||||
home_dir = tmp_path / "home"
|
||||
log_file = tmp_path / "curl.log"
|
||||
bin_dir.mkdir()
|
||||
home_dir.mkdir()
|
||||
curl = bin_dir / "curl"
|
||||
curl.write_text(
|
||||
f"""#!/bin/sh
|
||||
printf '%s\\n' "$*" >> {log_file}
|
||||
output_file=""
|
||||
previous=""
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = "-o" ]; then
|
||||
output_file="$argument"
|
||||
fi
|
||||
previous="$argument"
|
||||
done
|
||||
case "$*" in
|
||||
*'/karldns-update.sh'*)
|
||||
cat > "$output_file" <<'EOS'
|
||||
#!/bin/sh
|
||||
printf 'updater placeholder\\n'
|
||||
EOS
|
||||
;;
|
||||
*'/api/v1/hostnames'*)
|
||||
printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}'
|
||||
;;
|
||||
*'/api/v1/router/register/manage-secret'*)
|
||||
printf '%s\\n' 'good registered'
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected curl call: %s\\n' "$*" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
curl.chmod(0o700)
|
||||
|
||||
result = run_script(
|
||||
"sh",
|
||||
str(ROOT / "scripts" / "install-linux.sh"),
|
||||
"--mode",
|
||||
"user",
|
||||
"--no-cron",
|
||||
"--no-run",
|
||||
env={
|
||||
"HOME": str(home_dir),
|
||||
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
|
||||
"KARLDNS_BASE_URL": "https://karldns.test",
|
||||
"KARLDNS_RAW_BASE_URL": "https://raw.karldns.test",
|
||||
},
|
||||
)
|
||||
|
||||
log = log_file.read_text(encoding="utf-8")
|
||||
assert "/api/v1/hostnames" in log
|
||||
assert "/api/v1/router/register/manage-secret" in log
|
||||
assert "KarlDNS: registering hostname" in result.stdout
|
||||
config = home_dir / ".config" / "karldns" / "updater.env"
|
||||
config_text = config.read_text(encoding="utf-8")
|
||||
assert "KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/update-secret'" in config_text
|
||||
assert "KARLDNS_HOSTNAME='abc.karldns.test'" in config_text
|
||||
|
||||
|
||||
def test_user_installer_and_uninstaller_manage_cron_idempotently(tmp_path: Path) -> None:
|
||||
bin_dir = tmp_path / "bin"
|
||||
home_dir = tmp_path / "home"
|
||||
log_file = tmp_path / "curl.log"
|
||||
crontab_file = tmp_path / "crontab.txt"
|
||||
bin_dir.mkdir()
|
||||
home_dir.mkdir()
|
||||
curl = bin_dir / "curl"
|
||||
crontab = bin_dir / "crontab"
|
||||
curl.write_text(
|
||||
f"""#!/bin/sh
|
||||
printf '%s\\n' "$*" >> {log_file}
|
||||
output_file=""
|
||||
previous=""
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = "-o" ]; then
|
||||
output_file="$argument"
|
||||
fi
|
||||
previous="$argument"
|
||||
done
|
||||
case "$*" in
|
||||
*'/karldns-update.sh'*)
|
||||
cat > "$output_file" <<'EOS'
|
||||
#!/bin/sh
|
||||
printf 'updater placeholder\\n'
|
||||
EOS
|
||||
;;
|
||||
*'/api/v1/hostnames'*)
|
||||
printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}'
|
||||
;;
|
||||
*'/api/v1/router/register/manage-secret'*)
|
||||
printf '%s\\n' 'good registered'
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected curl call: %s\\n' "$*" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
crontab.write_text(
|
||||
f"""#!/bin/sh
|
||||
if [ "$1" = "-l" ]; then
|
||||
cat {crontab_file} 2>/dev/null || exit 1
|
||||
exit 0
|
||||
fi
|
||||
cp "$1" {crontab_file}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
curl.chmod(0o700)
|
||||
crontab.chmod(0o700)
|
||||
env = {
|
||||
"HOME": str(home_dir),
|
||||
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
|
||||
"KARLDNS_BASE_URL": "https://karldns.test",
|
||||
"KARLDNS_RAW_BASE_URL": "https://raw.karldns.test",
|
||||
}
|
||||
|
||||
for _ in range(2):
|
||||
run_script(
|
||||
"sh",
|
||||
str(ROOT / "scripts" / "install-linux.sh"),
|
||||
"--mode",
|
||||
"user",
|
||||
"--no-run",
|
||||
env=env,
|
||||
)
|
||||
|
||||
cron_text = crontab_file.read_text(encoding="utf-8")
|
||||
log = log_file.read_text(encoding="utf-8")
|
||||
assert cron_text.count("# karldns-update start") == 1
|
||||
assert "KARLDNS_CONFIG=" in cron_text
|
||||
assert "karldns-update" in cron_text
|
||||
assert log.count("/api/v1/hostnames") == 1
|
||||
assert log.count("/api/v1/router/register/manage-secret") == 1
|
||||
assert (home_dir / ".local" / "bin" / "karldns-update").exists()
|
||||
|
||||
run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", env=env)
|
||||
|
||||
assert "# karldns-update start" not in crontab_file.read_text(encoding="utf-8")
|
||||
assert not (home_dir / ".local" / "bin" / "karldns-update").exists()
|
||||
assert (home_dir / ".config" / "karldns" / "updater.env").exists()
|
||||
|
||||
run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", "--delete-config", env=env)
|
||||
|
||||
assert not (home_dir / ".config" / "karldns").exists()
|
||||
assert not (home_dir / ".cache" / "karldns").exists()
|
||||
|
||||
|
||||
def test_hosted_script_routes_redirect_to_github(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
for path, target in {
|
||||
"/install.sh": "install-linux.sh",
|
||||
"/uninstall.sh": "uninstall-linux.sh",
|
||||
"/karldns-update.sh": "karldns-update.sh",
|
||||
}.items():
|
||||
response = client.get(path, follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"].endswith(f"/scripts/{target}")
|
||||
@@ -6,10 +6,10 @@ from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
|
||||
from router_dyndns.ddns_dns import validate_dns_provider_settings
|
||||
from router_dyndns.ddns_security import DASHBOARD_UNLOCK_COOKIE
|
||||
import karldns.services.ddns as ddns_module
|
||||
from karldns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
|
||||
from karldns.dns import validate_dns_provider_settings
|
||||
from karldns.security import DASHBOARD_UNLOCK_COOKIE
|
||||
|
||||
|
||||
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
|
||||
@@ -20,14 +20,11 @@ def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "public-target.ddns.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
},
|
||||
f"/api/v1/router/update/{'a' * 43}",
|
||||
params={"hostname": "public-target.ddns.example.net", "myip": "203.0.113.9"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 404
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
@@ -39,16 +36,13 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "other.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
f"/api/v1/router/update/{account['update_slug']}",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.9"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 200
|
||||
assert store.list_records()[0]["hostname"] == "home.example.net"
|
||||
assert store.get_account_by_hostname("other.example.net") is None
|
||||
|
||||
|
||||
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||
@@ -70,7 +64,7 @@ def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path, caplog) -> None:
|
||||
caplog.set_level(logging.INFO, logger="router_dyndns.access")
|
||||
caplog.set_level(logging.INFO, logger="karldns.access")
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
@@ -81,30 +75,22 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path
|
||||
account = app.state.store.create_account("home.example.net", "router")
|
||||
client = TestClient(app)
|
||||
|
||||
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.1"})
|
||||
client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": account["hostname"],
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
"myip": "203.0.113.2",
|
||||
},
|
||||
)
|
||||
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.1"})
|
||||
client.get(f"/api/v1/hostnames/{account['management_slug']}")
|
||||
|
||||
assert "/u/<secret>" in caplog.text
|
||||
assert "/nic/update?<redacted>" in caplog.text
|
||||
assert "/api/v1/router/update/<secret>" in caplog.text
|
||||
assert "/api/v1/hostnames/<secret>" in caplog.text
|
||||
assert account["update_slug"] not in caplog.text
|
||||
assert account["password"] not in caplog.text
|
||||
assert account["management_slug"] not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/u/short",
|
||||
"/api/v1/updates/short",
|
||||
"/api/v1/router/update/short",
|
||||
"/api/v1/router/register/short",
|
||||
"/api/v1/hostnames/short",
|
||||
"/m/short",
|
||||
"/api/v1/management/short",
|
||||
],
|
||||
)
|
||||
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
|
||||
@@ -174,7 +160,7 @@ def test_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/magic", data={"username": "router"})
|
||||
created = client.post("/")
|
||||
|
||||
assert created.status_code == 200
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
@@ -192,7 +178,7 @@ def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/magic", data={"username": "x" * 2000})
|
||||
response = client.post("/", data={"username": "x" * 2000})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
@@ -208,7 +194,7 @@ def test_invalid_utf8_form_body_is_rejected(tmp_path: Path) -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/magic",
|
||||
"/",
|
||||
content=b"username=\xff",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
@@ -229,7 +215,7 @@ def test_too_many_form_fields_are_rejected(tmp_path: Path) -> None:
|
||||
body = "&".join(f"k{index}=v" for index in range(101))
|
||||
|
||||
response = client.post(
|
||||
"/magic",
|
||||
"/",
|
||||
content=body.encode("ascii"),
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
@@ -249,8 +235,8 @@ def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> N
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post("/magic", data={"username": "alice"}, headers={"x-forwarded-for": "198.51.100.10"})
|
||||
second = client.post("/magic", data={"username": "bob"}, headers={"x-forwarded-for": "198.51.100.11"})
|
||||
first = client.post("/", headers={"x-forwarded-for": "198.51.100.10"})
|
||||
second = client.post("/", headers={"x-forwarded-for": "198.51.100.11"})
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 429
|
||||
@@ -260,8 +246,8 @@ def test_dns_delete_failure_is_logged_without_secret_management_slug(tmp_path: P
|
||||
def fail_delete(hostname, settings):
|
||||
raise DnsProviderError("dns down")
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
|
||||
monkeypatch.setattr(ddns_service, "delete_dns_records", fail_delete)
|
||||
caplog.set_level(logging.WARNING, logger="karldns.services.ddns")
|
||||
monkeypatch.setattr(ddns_module, "delete_dns_records", fail_delete)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
@@ -363,7 +349,7 @@ def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> N
|
||||
|
||||
|
||||
def test_cloudflare_publish_reconciles_provider_state(monkeypatch) -> None:
|
||||
import router_dyndns.dns.cloudflare as cloudflare
|
||||
import karldns.dns.cloudflare as cloudflare
|
||||
|
||||
calls = {"find": 0}
|
||||
|
||||
@@ -394,7 +380,7 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
|
||||
import dns.query
|
||||
import dns.rcode
|
||||
|
||||
from router_dyndns.dns.rfc2136 import delete_rfc2136_records
|
||||
from karldns.dns.rfc2136 import delete_rfc2136_records
|
||||
|
||||
class RefusedResponse:
|
||||
def rcode(self) -> int:
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
from karldns import DdnsSettings, DdnsStore, make_app
|
||||
|
||||
|
||||
def _csrf(html: str) -> str:
|
||||
@@ -23,14 +23,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
|
||||
response = client.post("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "Full update URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Register URL:" in response.text
|
||||
assert "Router fields" in response.text
|
||||
assert "CNAME" in response.text
|
||||
assert "Manual updates" in response.text
|
||||
@@ -40,8 +38,8 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
||||
assert "Public:" in response.text
|
||||
assert "Secret:" in response.text
|
||||
assert ".ddns.example.net" in response.text
|
||||
assert "/u/" in response.text
|
||||
assert "/nic/update?" in response.text
|
||||
assert "/api/v1/router/update/" in response.text
|
||||
assert "/api/v1/router/register/" in response.text
|
||||
assert "/m/" in response.text
|
||||
|
||||
|
||||
@@ -51,18 +49,18 @@ def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "custom hostnames are not supported; create a KarlDNS hostname and use a CNAME"
|
||||
assert response.status_code == 404
|
||||
assert app.state.store.count_accounts() == 0
|
||||
|
||||
|
||||
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
|
||||
def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
@@ -73,15 +71,16 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/magic")
|
||||
response = client.post("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "Full update URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Register URL:" in response.text
|
||||
assert "Private dashboard:" in response.text
|
||||
assert "Pending setup" in response.text
|
||||
assert "Register hostname" in response.text
|
||||
assert "Unregistered setups are removed after 24 hours." in response.text
|
||||
assert "Router fields" in response.text
|
||||
assert "CNAME" in response.text
|
||||
assert "FRITZ!Box fills in" in response.text
|
||||
@@ -91,17 +90,64 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
|
||||
assert ".ddns.example.net" in response.text
|
||||
|
||||
|
||||
def test_magic_get_redirects_to_home_form(tmp_path: Path) -> None:
|
||||
def test_public_examples_use_visitor_public_ip(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app, client=("8.8.8.8", 12345))
|
||||
|
||||
home = client.get("/")
|
||||
created = client.post("/")
|
||||
|
||||
assert home.status_code == 200
|
||||
assert created.status_code == 200
|
||||
assert "myip=8.8.8.8" in home.text
|
||||
assert "good 8.8.8.8" in home.text
|
||||
assert "myip=8.8.8.8" in created.text
|
||||
|
||||
|
||||
def test_generated_hostname_must_be_activated_before_updates_work(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/")
|
||||
management_slug = re.search(r'action="/m/([^"]+)/activate"', created.text).group(1)
|
||||
account = app.state.store.get_account_by_management_slug(management_slug)
|
||||
|
||||
assert account["accepted_at"] is None
|
||||
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
|
||||
activated = client.post(f"/m/{management_slug}/activate", data={"csrf": _csrf(created.text)})
|
||||
|
||||
assert activated.status_code == 200
|
||||
assert "Hostname registered" in activated.text
|
||||
assert app.state.store.get_account_by_management_slug(management_slug)["accepted_at"]
|
||||
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 200
|
||||
|
||||
|
||||
def test_old_magic_route_is_gone(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/magic", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/#create"
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
@@ -113,8 +159,6 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
assert '<link href="/admin.css?v=2" rel="stylesheet">' in management.text
|
||||
assert "home.ddns.example.net" in management.text
|
||||
assert "CNAME target:" in management.text
|
||||
assert "Benutzername (optional):" in management.text
|
||||
assert "Kennwort (optional):" in management.text
|
||||
assert "Router fields" in management.text
|
||||
assert "CNAME" in management.text
|
||||
assert "Manual updates" in management.text
|
||||
@@ -126,7 +170,7 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)})
|
||||
assert deleted.status_code == 200
|
||||
|
||||
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
update = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
assert update.status_code == 404
|
||||
|
||||
|
||||
@@ -170,22 +214,10 @@ def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> N
|
||||
assert "Update-URL:" not in locked.text
|
||||
assert "correct horse" not in locked.text
|
||||
|
||||
update = locked_client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
update = locked_client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
assert update.status_code == 200
|
||||
assert update.text == "good 203.0.113.9"
|
||||
|
||||
nic_update = locked_client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": account["hostname"],
|
||||
"myip": "203.0.113.10",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
assert nic_update.status_code == 200
|
||||
assert nic_update.text == "good 203.0.113.10"
|
||||
|
||||
bad_unlock = locked_client.post(
|
||||
f"/m/{account['management_slug']}/unlock",
|
||||
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
|
||||
@@ -333,7 +365,7 @@ def test_existing_dashboard_password_requires_current_password_to_change_or_remo
|
||||
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
|
||||
|
||||
|
||||
def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path: Path) -> None:
|
||||
def test_management_dashboard_rotates_update_url(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
@@ -349,7 +381,6 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
|
||||
dashboard = client.get(f"/m/{account['management_slug']}")
|
||||
assert dashboard.status_code == 200
|
||||
assert "New update URL" in dashboard.text
|
||||
assert "New password" in dashboard.text
|
||||
assert "data-secret-toggle" in dashboard.text
|
||||
assert 'type="password"' in dashboard.text
|
||||
|
||||
@@ -357,26 +388,10 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
|
||||
assert rotated_update.status_code == 200
|
||||
assert "Update URL changed. Update your router now." in rotated_update.text
|
||||
assert "data-secret-input" in rotated_update.text
|
||||
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
|
||||
rotated_password = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(rotated_update.text), "action": "password"})
|
||||
assert rotated_password.status_code == 200
|
||||
assert "Router password changed" in rotated_password.text
|
||||
assert "Full update URL:" in rotated_password.text
|
||||
assert "Kennwort (optional):" in rotated_password.text
|
||||
assert "data-secret-toggle" in rotated_password.text
|
||||
assert client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "home.ddns.example.net",
|
||||
"username": "router",
|
||||
"password": account["password"],
|
||||
"myip": "203.0.113.9",
|
||||
},
|
||||
).status_code == 401
|
||||
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)}
|
||||
assert "dashboard_update_url_rotated" in statuses
|
||||
assert "dashboard_router_credentials_rotated" in statuses
|
||||
assert "dashboard_router_credentials_rotated" not in statuses
|
||||
|
||||
|
||||
def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None:
|
||||
@@ -393,8 +408,8 @@ def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -
|
||||
assert rotated.status_code == 200
|
||||
assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"]
|
||||
assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"]
|
||||
assert client.get(f"/u/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
|
||||
assert client.get(f"/u/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
|
||||
assert client.get(f"/api/v1/router/update/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
|
||||
assert client.get(f"/api/v1/router/update/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
|
||||
|
||||
|
||||
def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None:
|
||||
@@ -437,30 +452,6 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="/admin"' not in response.text
|
||||
assert "Router login name" not in response.text
|
||||
assert 'name="username"' not in response.text
|
||||
assert "You get a hostname, a private update URL, and router fields if your router needs them." in response.text
|
||||
assert "Add a CNAME" in response.text
|
||||
assert "Linux cron updater" in response.text
|
||||
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
|
||||
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
|
||||
assert "--delete-config" in response.text
|
||||
|
||||
|
||||
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="karldns.de",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
@@ -469,11 +460,61 @@ def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) ->
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "https://karldns.de/u/<secret>" 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/<secret>" in response.text
|
||||
assert "https://karldns.de/api/v1/router/update/<secret>" not in response.text
|
||||
assert "http://127.0.0.1" not in response.text
|
||||
assert "http://localhost" not in response.text
|
||||
|
||||
|
||||
def test_public_examples_use_karldns_domain_and_no_shell_prompt(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "https://karldns.de/api/v1/router/update/<secret>" in response.text
|
||||
assert "http://127.0.0.1:8099/api/v1/router/update/<secret>" not in response.text
|
||||
assert "$ curl" not in response.text
|
||||
assert 'data-copy="curl -sS 'https://karldns.de/api/v1/router/update/<secret>?myip=203.0.113.10'"' in response.text
|
||||
|
||||
|
||||
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
@@ -485,7 +526,7 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
||||
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
|
||||
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text
|
||||
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
|
||||
assert '<link href="/app.css?v=5" rel="stylesheet">' in response.text
|
||||
assert '<link href="/app.css?v=12" rel="stylesheet">' in response.text
|
||||
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
|
||||
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
|
||||
assert "<style>" not in response.text
|
||||
@@ -534,6 +575,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||
assert "Made with" in response.text
|
||||
assert "❤️" in response.text
|
||||
assert "by karl" in response.text
|
||||
assert response.text.count('class="lucide-icon footer-icon"') == 5
|
||||
assert 'class="lucide-icon theme-icon theme-icon-moon"' in response.text
|
||||
assert 'class="lucide-icon copy-icon"' in response.text
|
||||
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
|
||||
assert 'href="https://karl.fail"' in response.text
|
||||
assert 'href="https://karlcom.de"' in response.text
|
||||
@@ -546,7 +590,7 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||
assert "WebPage" in privacy.text
|
||||
assert "Legal and privacy" in privacy.text
|
||||
assert "Data processed" in privacy.text
|
||||
assert "protected router passwords" in privacy.text
|
||||
assert "private update links" in privacy.text
|
||||
assert "Made with" in privacy.text
|
||||
assert "❤️" in privacy.text
|
||||
assert "by karl" in privacy.text
|
||||
|
||||