Simplify DDNS flow and polish UI
No files matched your search
@@ -25,7 +25,6 @@ DDNS_TTL=60
|
|||||||
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
|
# DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20
|
||||||
# DDNS_MAX_REQUEST_BODY_BYTES=16384
|
# DDNS_MAX_REQUEST_BODY_BYTES=16384
|
||||||
# DDNS_REDACTED_ACCESS_LOG=0
|
# DDNS_REDACTED_ACCESS_LOG=0
|
||||||
# DDNS_CLEANUP_CHALLENGE_HOURS=72
|
|
||||||
# DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
|
# DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080
|
||||||
# DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320
|
# DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320
|
||||||
# DDNS_CLEANUP_INTERVAL_SECONDS=3600
|
# DDNS_CLEANUP_INTERVAL_SECONDS=3600
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Self-hosted Dynamic DNS (DynDNS / DDNS) for FRITZ!Box, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, Linux cron jobs, and any router that can call a custom DDNS update URL.**
|
**Self-hosted Dynamic DNS (DynDNS / DDNS) for FRITZ!Box, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, Linux cron jobs, and any router that can call a custom DDNS update URL.**
|
||||||
|
|
||||||
KarlDNS is the friendly name for `karldns`: a small FastAPI Dynamic DNS provider you can run on your own VPS. It gives users accountless private update URLs, FRITZ!Box-ready DynDNS fields, public A/AAAA records through Cloudflare or RFC 2136, generated hostnames that work as CNAME targets, and verified custom domains with DNS TXT ownership checks.
|
KarlDNS is the friendly name for `karldns`: a small FastAPI Dynamic DNS provider you can run on your own VPS. It gives users accountless private update URLs, FRITZ!Box-ready DynDNS fields, public A/AAAA records through Cloudflare or RFC 2136, and generated hostnames that also work as CNAME targets for user-owned domains.
|
||||||
|
|
||||||
If you want a lightweight DuckDNS / Dynu / No-IP style service that you control yourself, KarlDNS is built for that use case.
|
If you want a lightweight DuckDNS / Dynu / No-IP style service that you control yourself, KarlDNS is built for that use case.
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control
|
|||||||
- **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure.
|
- **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure.
|
||||||
- **FRITZ!Box compatible**: copy the generated `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` straight into the German FRITZ!Box DynDNS form.
|
- **FRITZ!Box compatible**: copy the generated `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` straight into the German FRITZ!Box DynDNS form.
|
||||||
- **CNAME-friendly hostnames**: users can use the generated hostname directly, or point `home.example.com` to it with a CNAME.
|
- **CNAME-friendly hostnames**: users can use the generated hostname directly, or point `home.example.com` to it with a CNAME.
|
||||||
- **No registration required**: provider hostnames and custom-domain setup both use private magic links.
|
- **No registration required**: generated hostnames use private magic links, with no accounts or login flow.
|
||||||
- **Custom domain support**: users enter a domain, save the private claim link, add a DNS TXT record, then press a button to verify ownership before credentials are issued.
|
- **CNAME alias support**: users create one KarlDNS hostname, then point `home.example.com` to it at their own DNS provider.
|
||||||
- **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support.
|
- **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support.
|
||||||
- **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
|
- **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
|
||||||
- **API first**: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under `/api/v1`.
|
- **API first**: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under `/api/v1`.
|
||||||
@@ -25,10 +25,6 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Verify a Custom Domain
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Logo And Social Preview
|
## Logo And Social Preview
|
||||||
|
|
||||||
KarlDNS ships web-ready brand assets so self-hosted instances and shared links look clean out of the box:
|
KarlDNS ships web-ready brand assets so self-hosted instances and shared links look clean out of the box:
|
||||||
@@ -47,7 +43,7 @@ KarlDNS ships web-ready brand assets so self-hosted instances and shared links l
|
|||||||
KarlDNS lets you host your own managed DynDNS service:
|
KarlDNS lets you host your own managed DynDNS service:
|
||||||
|
|
||||||
1. A user opens your KarlDNS URL.
|
1. A user opens your KarlDNS URL.
|
||||||
2. They generate a random hostname like `a1b2c3d4.karldns.de`, or verify their own domain with a TXT record.
|
2. They generate a random hostname like `a1b2c3d4.karldns.de`.
|
||||||
3. KarlDNS shows the exact router settings for the DynDNS form.
|
3. KarlDNS shows the exact router settings for the DynDNS form.
|
||||||
4. The router periodically calls the update URL with its current public IPv4/IPv6 address.
|
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.
|
5. KarlDNS publishes the matching DNS records through your configured DNS backend.
|
||||||
@@ -60,7 +56,6 @@ That means users always have a current hostname for VPN, remote access, self-hos
|
|||||||
- Self-hosted DynDNS for FRITZ!Box routers.
|
- Self-hosted DynDNS for FRITZ!Box routers.
|
||||||
- Free DDNS service for friends, customers, a community, or a home lab.
|
- Free DDNS service for friends, customers, a community, or a home lab.
|
||||||
- Dynamic DNS for IPv4 and IPv6 home internet connections.
|
- Dynamic DNS for IPv4 and IPv6 home internet connections.
|
||||||
- Custom domain DDNS with DNS TXT verification.
|
|
||||||
- CNAME target hostnames for users who want `home.example.com` without sharing DNS API access.
|
- CNAME target hostnames for users who want `home.example.com` without sharing DNS API access.
|
||||||
- Lightweight Cloudflare DDNS provider with a web UI.
|
- Lightweight Cloudflare DDNS provider with a web UI.
|
||||||
- RFC 2136 / TSIG DynDNS frontend for BIND, Knot, PowerDNS, or compatible DNS servers.
|
- RFC 2136 / TSIG DynDNS frontend for BIND, Knot, PowerDNS, or compatible DNS servers.
|
||||||
@@ -73,10 +68,10 @@ That means users always have a current hostname for VPN, remote access, self-hos
|
|||||||
| DNS records | A and AAAA |
|
| DNS records | A and AAAA |
|
||||||
| DNS providers | Cloudflare API and RFC 2136 dynamic DNS |
|
| DNS providers | Cloudflare API and RFC 2136 dynamic DNS |
|
||||||
| Generated hostnames | Random provider-owned names that can be used directly or as CNAME targets |
|
| Generated hostnames | Random provider-owned names that can be used directly or as CNAME targets |
|
||||||
| Custom domains | TXT challenge verification before hostname creation |
|
| User-owned DNS names | CNAME only. KarlDNS publishes the generated provider-owned hostname; the user's DNS provider serves the alias |
|
||||||
| Persistence | SQLite with WAL mode |
|
| Persistence | SQLite with WAL mode |
|
||||||
| API | FastAPI, OpenAPI, Swagger UI, ReDoc |
|
| API | FastAPI, OpenAPI, Swagger UI, ReDoc |
|
||||||
| Auth model | No user accounts. Private bearer links for hostnames and custom-domain claims |
|
| Auth model | No user accounts. Private bearer links for hostnames and dashboards |
|
||||||
| Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example |
|
| Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -109,7 +104,6 @@ Open:
|
|||||||
- ReDoc: `http://localhost:8080/redoc`
|
- ReDoc: `http://localhost:8080/redoc`
|
||||||
|
|
||||||
Admin auth uses HTTP Basic auth. The username can be any value; the password is `DDNS_ADMIN_PASSWORD`.
|
Admin auth uses HTTP Basic auth. The username can be any value; the password is `DDNS_ADMIN_PASSWORD`.
|
||||||
`DDNS_SHARED_SECRET`, when configured for legacy updater clients, is not accepted for admin routes.
|
|
||||||
|
|
||||||
The public OpenAPI schema documents the developer API under `/api/v1`. Admin/operator endpoints are intentionally omitted from Swagger UI and ReDoc.
|
The public OpenAPI schema documents the developer API under `/api/v1`. Admin/operator endpoints are intentionally omitted from Swagger UI and ReDoc.
|
||||||
|
|
||||||
@@ -146,7 +140,7 @@ export DDNS_RFC2136_KEY_SECRET='replace-with-tsig-secret'
|
|||||||
export DDNS_TTL=60
|
export DDNS_TTL=60
|
||||||
```
|
```
|
||||||
|
|
||||||
`DDNS_DNS_ZONES` is the allowlist of DNS zones this service may publish. A custom hostname must be inside one of these zones.
|
`DDNS_DNS_ZONES` is the allowlist of DNS zones this service may publish. For the public KarlDNS model this is normally only your provider zone, such as `karldns.de`.
|
||||||
|
|
||||||
## FRITZ!Box DynDNS Setup
|
## FRITZ!Box DynDNS Setup
|
||||||
|
|
||||||
@@ -175,7 +169,7 @@ This URL is enough for routers that only expose a single update URL field and do
|
|||||||
The compatibility endpoint is also available:
|
The compatibility endpoint is also available:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://karldns.de/nic/update?hostname=<domain>&myip=<ipaddr>&myipv6=<ip6addr>&username=<username>&password=<pass>
|
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.
|
Prefer `/u/<slug>` for managed service use because the router request cannot change the hostname.
|
||||||
@@ -198,7 +192,7 @@ 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:
|
The `/nic/update` compatibility endpoint also works for clients that expect hostname and credentials in the URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS 'https://karldns.de/nic/update?hostname=home.example.net&username=router&password=<password>&myip=203.0.113.10'
|
curl -sS 'https://karldns.de/nic/update?hostname=a1b2c3d4.karldns.de&username=router&password=<password>&myip=203.0.113.10'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linux Cron Installer
|
### Linux Cron Installer
|
||||||
@@ -282,7 +276,7 @@ curl -fsSL https://karldns.de/install.sh | sudo sh -s -- --ipv6 off
|
|||||||
|
|
||||||
## Use Your Own Subdomain With CNAME
|
## Use Your Own Subdomain With CNAME
|
||||||
|
|
||||||
Most users do not need domain verification. Create a KarlDNS hostname first, then create a CNAME at your DNS provider:
|
To use your own domain, create a KarlDNS hostname first, then create a CNAME at your DNS provider:
|
||||||
|
|
||||||
```dns
|
```dns
|
||||||
home.example.com. CNAME a1b2c3d4.karldns.de.
|
home.example.com. CNAME a1b2c3d4.karldns.de.
|
||||||
@@ -292,21 +286,9 @@ Put the KarlDNS `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` into
|
|||||||
|
|
||||||
Use a subdomain such as `home.example.com`. Root/apex domains like `example.com` usually cannot be plain CNAME records unless your DNS provider supports CNAME flattening or ALIAS/ANAME records.
|
Use a subdomain such as `home.example.com`. Root/apex domains like `example.com` usually cannot be plain CNAME records unless your DNS provider supports CNAME flattening or ALIAS/ANAME records.
|
||||||
|
|
||||||
The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, router password, private dashboard URL, and domain claim link are the secrets. Dashboard forms use CSRF tokens, and dashboard password unlock cookies last 24 hours by default unless the user chooses the 30-day stay-unlocked option.
|
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.
|
||||||
|
|
||||||
## Custom Domain Flow
|
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.
|
||||||
|
|
||||||
This flow is only needed when KarlDNS should publish A/AAAA records directly inside a user-owned domain instead of using a CNAME target.
|
|
||||||
|
|
||||||
Custom-domain publishing is intentionally simple:
|
|
||||||
|
|
||||||
1. Enter the domain.
|
|
||||||
2. Save the private claim link.
|
|
||||||
3. Add the generated TXT record at your DNS provider.
|
|
||||||
4. Press **I added it, check DNS**.
|
|
||||||
5. Create router credentials after the TXT record verifies.
|
|
||||||
|
|
||||||
KarlDNS stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. Claims created from a hostname dashboard are bound to that dashboard owner, so another dashboard cannot verify or use them. The private claim link is the bearer credential for returning later after DNS propagation.
|
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
@@ -317,9 +299,6 @@ The JSON API is available under `/api/v1`.
|
|||||||
| `POST /api/v1/hostnames/magic` | Create an anonymous provider-owned hostname. |
|
| `POST /api/v1/hostnames/magic` | Create an anonymous provider-owned hostname. |
|
||||||
| `GET /api/v1/management/{management_slug}` | Inspect a hostname by private management link. |
|
| `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. |
|
| `DELETE /api/v1/management/{management_slug}` | Delete a hostname and its DNS records. |
|
||||||
| `POST /api/v1/domains/challenges` | Create a TXT challenge and private claim secret. |
|
|
||||||
| `POST /api/v1/domains/verify` | Verify a TXT challenge with the claim secret. |
|
|
||||||
| `POST /api/v1/hostnames/custom` | Create a custom hostname below a verified domain with the claim secret. |
|
|
||||||
| `GET /api/v1/updates/{update_slug}` | JSON update endpoint for routers or automation. |
|
| `GET /api/v1/updates/{update_slug}` | JSON update endpoint for routers or automation. |
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@@ -374,7 +353,6 @@ Security and cleanup defaults:
|
|||||||
| `DDNS_DASHBOARD_COOKIE_SECRET` | `DDNS_ADMIN_PASSWORD` fallback | HMAC signing secret for dashboard unlock cookies. Use a separate 32+ character random value in production. |
|
| `DDNS_DASHBOARD_COOKIE_SECRET` | `DDNS_ADMIN_PASSWORD` fallback | HMAC signing secret for dashboard unlock cookies. Use a separate 32+ character random value in production. |
|
||||||
| `DDNS_REDACTED_ACCESS_LOG` | `0` | Enable KarlDNS request logs with bearer paths and update query strings redacted. |
|
| `DDNS_REDACTED_ACCESS_LOG` | `0` | Enable KarlDNS request logs with bearer paths and update query strings redacted. |
|
||||||
| `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. |
|
| `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. |
|
||||||
| `DDNS_CLEANUP_CHALLENGE_HOURS` | `72` | Delete unverified DNS TXT claims after this many hours. |
|
|
||||||
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete generated hostnames that never received a router update after this many hours. |
|
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `1080` | Delete generated hostnames that never received a router update after this many hours. |
|
||||||
| `DDNS_CLEANUP_EVENT_HISTORY_HOURS` | `4320` | Delete old raw update/audit events after this many hours. Rollups and recent events remain visible. |
|
| `DDNS_CLEANUP_EVENT_HISTORY_HOURS` | `4320` | Delete old raw update/audit events after this many hours. Rollups and recent events remain visible. |
|
||||||
| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Store the cleanup task cadence in SQLite and schedule the next cleanup run at this interval. |
|
| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Store the cleanup task cadence in SQLite and schedule the next cleanup run at this interval. |
|
||||||
@@ -403,7 +381,7 @@ Backend layout:
|
|||||||
| `router_dyndns/dependencies.py` | Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. |
|
| `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/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/web/` | HTML rendering, form parsing, and security-header/body-limit support code. |
|
||||||
| `router_dyndns/ddns_service.py` | Domain logic for account creation, verification, and IP updates. |
|
| `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/store/` | SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. |
|
||||||
| `router_dyndns/ddns_store.py` | Compatibility export for `DdnsStore`. |
|
| `router_dyndns/ddns_store.py` | Compatibility export for `DdnsStore`. |
|
||||||
| `router_dyndns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
|
| `router_dyndns/dns/` | Cloudflare and RFC 2136 DNS publishing. |
|
||||||
@@ -421,7 +399,7 @@ Self-hosted DynDNS, self hosted DDNS, Dynamic DNS server, FRITZ!Box DynDNS, FRIT
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
The repository intentionally contains only placeholders in `.env.example` and documentation. Do not commit a real `.env`, DNS token, TSIG secret, admin password, database, private status page URL, domain claim URL, or router-generated update URL. Generated hostnames and CNAME targets are public identifiers, not authentication secrets.
|
The repository intentionally contains only placeholders in `.env.example` and documentation. Do not commit a real `.env`, DNS token, TSIG secret, admin password, database, private dashboard URL, or router-generated update URL. Generated hostnames and CNAME targets are public identifiers, not authentication secrets.
|
||||||
|
|
||||||
KarlDNS does not need secret-bearing query strings in application logs. The bundled server command leaves Uvicorn access logs disabled by default; configure reverse-proxy logs the same way or redact full request URLs.
|
KarlDNS does not need secret-bearing query strings in application logs. The bundled server command leaves Uvicorn access logs disabled by default; configure reverse-proxy logs the same way or redact full request URLs.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 627 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 106 KiB |
@@ -34,11 +34,7 @@ __all__ = [
|
|||||||
OPENAPI_TAGS = [
|
OPENAPI_TAGS = [
|
||||||
{
|
{
|
||||||
"name": "hostnames",
|
"name": "hostnames",
|
||||||
"description": "Create KarlDNS hostnames or hostnames under verified domains.",
|
"description": "Create KarlDNS hostnames that can be used directly or as CNAME targets.",
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "domains",
|
|
||||||
"description": "Issue and verify DNS TXT challenges for custom domains.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "updates",
|
"name": "updates",
|
||||||
@@ -84,8 +80,8 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
title="KarlDNS",
|
title="KarlDNS",
|
||||||
summary="A small self-hosted DynDNS provider for FRITZ!Box routers.",
|
summary="A small self-hosted DynDNS provider for FRITZ!Box routers.",
|
||||||
description=(
|
description=(
|
||||||
"Generate cryptographically random DynDNS endpoints, verify custom domains with DNS TXT "
|
"Generate cryptographically random DynDNS endpoints and publish A/AAAA records "
|
||||||
"records, and publish A/AAAA records through Cloudflare or RFC 2136."
|
"for KarlDNS-managed hostnames through Cloudflare or RFC 2136."
|
||||||
),
|
),
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
@@ -137,7 +133,6 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None:
|
|||||||
try:
|
try:
|
||||||
await run_in_threadpool(
|
await run_in_threadpool(
|
||||||
store.cleanup,
|
store.cleanup,
|
||||||
settings.cleanup_challenge_hours,
|
|
||||||
settings.cleanup_unused_account_hours,
|
settings.cleanup_unused_account_hours,
|
||||||
settings.cleanup_event_history_hours,
|
settings.cleanup_event_history_hours,
|
||||||
)
|
)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
@@ -9,14 +9,11 @@ from .ddns_models import DdnsSettings
|
|||||||
def settings_from_env() -> DdnsSettings:
|
def settings_from_env() -> DdnsSettings:
|
||||||
return DdnsSettings(
|
return DdnsSettings(
|
||||||
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
|
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
|
||||||
shared_secret=os.getenv("DDNS_SHARED_SECRET", ""),
|
|
||||||
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", ""),
|
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", ""),
|
||||||
admin_path=os.getenv("DDNS_ADMIN_PATH", "/admin"),
|
admin_path=os.getenv("DDNS_ADMIN_PATH", "/admin"),
|
||||||
allowed_hosts=_csv_set("DDNS_ALLOWED_HOSTS"),
|
|
||||||
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
|
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||||
trusted_hosts=_csv_set("DDNS_TRUSTED_HOSTS"),
|
trusted_hosts=_csv_set("DDNS_TRUSTED_HOSTS"),
|
||||||
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
|
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
|
||||||
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_karldns"),
|
|
||||||
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
|
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
|
||||||
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20),
|
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20),
|
||||||
max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
|
max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
|
||||||
@@ -30,7 +27,6 @@ def settings_from_env() -> DdnsSettings:
|
|||||||
require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"),
|
require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"),
|
||||||
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
|
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
|
||||||
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
|
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
|
||||||
cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72),
|
|
||||||
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080),
|
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080),
|
||||||
cleanup_event_history_hours=_env_int("DDNS_CLEANUP_EVENT_HISTORY_HOURS", 24 * 180),
|
cleanup_event_history_hours=_env_int("DDNS_CLEANUP_EVENT_HISTORY_HOURS", 24 * 180),
|
||||||
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
|
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
CustomHostnameRequest,
|
|
||||||
DomainChallengeRequest,
|
|
||||||
DomainChallengeResponse,
|
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
HostnameCredentials,
|
HostnameCredentials,
|
||||||
MagicHostnameRequest,
|
MagicHostnameRequest,
|
||||||
ManagedRecord,
|
ManagedRecord,
|
||||||
RouterUpdateResponse,
|
RouterUpdateResponse,
|
||||||
VerifyDomainRequest,
|
|
||||||
VerifyDomainResponse,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CustomHostnameRequest",
|
|
||||||
"DomainChallengeRequest",
|
|
||||||
"DomainChallengeResponse",
|
|
||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
"HostnameCredentials",
|
"HostnameCredentials",
|
||||||
"MagicHostnameRequest",
|
"MagicHostnameRequest",
|
||||||
"ManagedRecord",
|
"ManagedRecord",
|
||||||
"RouterUpdateResponse",
|
"RouterUpdateResponse",
|
||||||
"VerifyDomainRequest",
|
|
||||||
"VerifyDomainResponse",
|
|
||||||
]
|
]
|
||||||
@@ -12,14 +12,11 @@ class DdnsSettings(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
database_path: Path = Field(default=Path("ddns.sqlite3"))
|
database_path: Path = Field(default=Path("ddns.sqlite3"))
|
||||||
shared_secret: str = Field(default="")
|
|
||||||
admin_password: str = Field(default="")
|
admin_password: str = Field(default="")
|
||||||
admin_path: str = Field(default="/admin")
|
admin_path: str = Field(default="/admin")
|
||||||
allowed_hosts: set[str] = Field(default_factory=set)
|
|
||||||
public_base_url: str = Field(default="http://localhost:8080")
|
public_base_url: str = Field(default="http://localhost:8080")
|
||||||
trusted_hosts: set[str] = Field(default_factory=set)
|
trusted_hosts: set[str] = Field(default_factory=set)
|
||||||
hostname_suffix: str = Field(default="")
|
hostname_suffix: str = Field(default="")
|
||||||
verification_prefix: str = Field(default="_karldns")
|
|
||||||
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
||||||
admin_rate_limit_per_minute: int = Field(default=20, ge=1, le=10_000)
|
admin_rate_limit_per_minute: int = Field(default=20, ge=1, le=10_000)
|
||||||
max_request_body_bytes: int = Field(default=16_384, ge=1024, le=1_048_576)
|
max_request_body_bytes: int = Field(default=16_384, ge=1024, le=1_048_576)
|
||||||
@@ -33,7 +30,6 @@ class DdnsSettings(BaseModel):
|
|||||||
require_dns_provider: bool = Field(default=False)
|
require_dns_provider: bool = Field(default=False)
|
||||||
cloudflare_api_token: str = Field(default="")
|
cloudflare_api_token: str = Field(default="")
|
||||||
cloudflare_zone_id: str = Field(default="")
|
cloudflare_zone_id: str = Field(default="")
|
||||||
cleanup_challenge_hours: int = Field(default=72, ge=1, le=24 * 365)
|
|
||||||
cleanup_unused_account_hours: int = Field(default=1080, ge=1, le=24 * 365)
|
cleanup_unused_account_hours: int = Field(default=1080, ge=1, le=24 * 365)
|
||||||
cleanup_event_history_hours: int = Field(default=24 * 180, ge=1, le=24 * 3650)
|
cleanup_event_history_hours: int = Field(default=24 * 180, ge=1, le=24 * 3650)
|
||||||
cleanup_interval_seconds: int = Field(default=3600, ge=1, le=24 * 60 * 60)
|
cleanup_interval_seconds: int = Field(default=3600, ge=1, le=24 * 60 * 60)
|
||||||
@@ -69,9 +65,7 @@ class DdnsSettings(BaseModel):
|
|||||||
"/admin-tables.js",
|
"/admin-tables.js",
|
||||||
"/api",
|
"/api",
|
||||||
"/app.js",
|
"/app.js",
|
||||||
"/claim",
|
|
||||||
"/clipboard.js",
|
"/clipboard.js",
|
||||||
"/d",
|
|
||||||
"/docs",
|
"/docs",
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
"/healthz",
|
"/healthz",
|
||||||
|
|||||||
@@ -156,17 +156,15 @@ def is_rate_limited_path(path: str) -> bool:
|
|||||||
limited_paths = {
|
limited_paths = {
|
||||||
"/magic",
|
"/magic",
|
||||||
"/accounts",
|
"/accounts",
|
||||||
"/request-domain",
|
|
||||||
"/verify-domain",
|
|
||||||
"/nic/update",
|
"/nic/update",
|
||||||
}
|
}
|
||||||
return path in limited_paths or path.startswith(("/u/", "/m/", "/d/", "/api/v1/updates/")) or path.startswith(
|
return path in limited_paths or path.startswith(("/u/", "/m/", "/api/v1/updates/")) or path.startswith(
|
||||||
("/api/v1/hostnames/", "/api/v1/domains/", "/api/v1/management/")
|
("/api/v1/hostnames/", "/api/v1/management/")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def rate_limit_bucket(path: str) -> str:
|
def rate_limit_bucket(path: str) -> str:
|
||||||
for prefix in ("/u/", "/m/", "/d/", "/api/v1/updates/", "/api/v1/management/"):
|
for prefix in ("/u/", "/m/", "/api/v1/updates/", "/api/v1/management/"):
|
||||||
if path.startswith(prefix):
|
if path.startswith(prefix):
|
||||||
return prefix.rstrip("/")
|
return prefix.rstrip("/")
|
||||||
return path
|
return path
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
|
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
|
||||||
from .ddns_models import DdnsSettings, UpdateResult
|
from .ddns_models import DdnsSettings, UpdateResult
|
||||||
from .ddns_security import client_ip
|
from .ddns_security import client_ip
|
||||||
from .ddns_store import DdnsStore
|
from .ddns_store import DdnsStore
|
||||||
@@ -49,56 +49,6 @@ class DdnsService:
|
|||||||
continue
|
continue
|
||||||
raise HTTPException(status_code=500, detail="could not allocate hostname")
|
raise HTTPException(status_code=500, detail="could not allocate hostname")
|
||||||
|
|
||||||
def create_custom_account(
|
|
||||||
self,
|
|
||||||
hostname: str,
|
|
||||||
claim_secret: str,
|
|
||||||
username: str | None = None,
|
|
||||||
owner_hostname: str | None = None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
normalized = normalize_hostname(hostname, self.settings, expand_suffix=False)
|
|
||||||
if not normalized:
|
|
||||||
raise HTTPException(status_code=400, detail="valid custom hostname is required")
|
|
||||||
if not self.hostname_is_publishable(normalized):
|
|
||||||
raise HTTPException(status_code=403, detail="hostname is outside configured DNS publishing zones")
|
|
||||||
if not self.best_verified_parent(normalized, claim_secret, owner_hostname=owner_hostname):
|
|
||||||
raise HTTPException(status_code=403, detail="verify the parent domain before creating this hostname")
|
|
||||||
try:
|
|
||||||
return self.store.create_account(normalized, username)
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail="hostname already exists") from exc
|
|
||||||
|
|
||||||
def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> dict[str, str]:
|
|
||||||
normalized = normalize_domain(domain)
|
|
||||||
if not normalized:
|
|
||||||
raise HTTPException(status_code=400, detail="valid domain is required")
|
|
||||||
existing = self.store.get_domain_challenge(normalized)
|
|
||||||
if existing and existing.get("verified_at"):
|
|
||||||
raise HTTPException(status_code=409, detail="domain is already verified")
|
|
||||||
return self.store.create_domain_challenge(normalized, owner_hostname=owner_hostname)
|
|
||||||
|
|
||||||
def verify_domain(
|
|
||||||
self,
|
|
||||||
domain: str,
|
|
||||||
claim_secret: str,
|
|
||||||
owner_hostname: str | None = None,
|
|
||||||
) -> tuple[bool, dict[str, str | None]]:
|
|
||||||
normalized = normalize_domain(domain)
|
|
||||||
if not normalized:
|
|
||||||
raise HTTPException(status_code=400, detail="valid domain is required")
|
|
||||||
challenge = self.store.get_domain_challenge(normalized, claim_secret=claim_secret, owner_hostname=owner_hostname)
|
|
||||||
if not challenge:
|
|
||||||
raise HTTPException(status_code=404, detail="domain challenge not found")
|
|
||||||
found = dns_txt_contains(self.verification_name(normalized), str(challenge["token"]))
|
|
||||||
if not found:
|
|
||||||
challenge = dict(challenge)
|
|
||||||
challenge["claim_secret"] = claim_secret
|
|
||||||
return False, challenge
|
|
||||||
verified = self.store.verify_domain_challenge(normalized, claim_secret, owner_hostname=owner_hostname)
|
|
||||||
if verified is None:
|
|
||||||
raise HTTPException(status_code=404, detail="domain challenge not found")
|
|
||||||
return True, verified
|
|
||||||
|
|
||||||
def delete_account(self, hostname: str, source: str) -> None:
|
def delete_account(self, hostname: str, source: str) -> None:
|
||||||
try:
|
try:
|
||||||
delete_dns_records(hostname, self.settings)
|
delete_dns_records(hostname, self.settings)
|
||||||
@@ -213,49 +163,18 @@ class DdnsService:
|
|||||||
slug = urllib.parse.quote(account["management_slug"])
|
slug = urllib.parse.quote(account["management_slug"])
|
||||||
return f"{base}/m/{slug}"
|
return f"{base}/m/{slug}"
|
||||||
|
|
||||||
def domain_claim_url(self, challenge: dict[str, str | None]) -> str:
|
|
||||||
base = self.settings.public_base_url.rstrip("/")
|
|
||||||
secret = urllib.parse.quote(str(challenge["claim_secret"]))
|
|
||||||
return f"{base}/d/{secret}"
|
|
||||||
|
|
||||||
def random_managed_hostname(self) -> str:
|
def random_managed_hostname(self) -> str:
|
||||||
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
||||||
if not suffix:
|
if not suffix:
|
||||||
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
|
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
|
||||||
return f"{secrets.token_hex(6)}.{suffix}"
|
return f"{secrets.token_hex(6)}.{suffix}"
|
||||||
|
|
||||||
def best_verified_parent(
|
def hostname_is_managed(self, hostname: str) -> bool:
|
||||||
self,
|
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
||||||
hostname: str,
|
if not suffix:
|
||||||
claim_secret: str | None = None,
|
return True
|
||||||
owner_hostname: str | None = None,
|
normalized = hostname.strip().lower().rstrip(".")
|
||||||
) -> str | None:
|
return normalized == suffix or normalized.endswith(f".{suffix}")
|
||||||
labels = hostname.split(".")
|
|
||||||
for index in range(1, len(labels) - 1):
|
|
||||||
candidate = ".".join(labels[index:])
|
|
||||||
if self.store.is_domain_verified(candidate, claim_secret, owner_hostname=owner_hostname):
|
|
||||||
return candidate
|
|
||||||
if self.store.is_domain_verified(hostname, claim_secret, owner_hostname=owner_hostname):
|
|
||||||
return hostname
|
|
||||||
return None
|
|
||||||
|
|
||||||
def verification_name(self, domain: str) -> str:
|
|
||||||
prefix = self.settings.verification_prefix.strip().strip(".") or "_karldns"
|
|
||||||
return f"{prefix}.{domain}"
|
|
||||||
|
|
||||||
def hostname_is_publishable(self, hostname: str) -> bool:
|
|
||||||
if not dns_backend_configured(self.settings):
|
|
||||||
return not self.settings.require_dns_provider
|
|
||||||
zones = {
|
|
||||||
zone.strip().lower().strip(".")
|
|
||||||
for zone in self.settings.dns_zones
|
|
||||||
if zone.strip()
|
|
||||||
}
|
|
||||||
if not zones and self.settings.rfc2136_zone:
|
|
||||||
zones.add(self.settings.rfc2136_zone.strip().lower().strip("."))
|
|
||||||
if not zones and self.settings.hostname_suffix:
|
|
||||||
zones.add(self.settings.hostname_suffix.strip().lower().strip("."))
|
|
||||||
return bool(zones) and any(hostname == zone or hostname.endswith(f".{zone}") for zone in zones)
|
|
||||||
|
|
||||||
def _lock_for(self, hostname: str) -> threading.Lock:
|
def _lock_for(self, hostname: str) -> threading.Lock:
|
||||||
return self._locks[hash(hostname) % self._LOCK_STRIPES]
|
return self._locks[hash(hostname) % self._LOCK_STRIPES]
|
||||||
@@ -320,28 +239,6 @@ def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool =
|
|||||||
return hostname
|
return hostname
|
||||||
|
|
||||||
|
|
||||||
def normalize_domain(value: str) -> str:
|
|
||||||
domain = value.strip().lower().rstrip(".")
|
|
||||||
if not _valid_domain_name(domain):
|
|
||||||
return ""
|
|
||||||
return domain
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_domain_name(value: str) -> bool:
|
def _valid_domain_name(value: str) -> bool:
|
||||||
label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
||||||
return bool(re.fullmatch(rf"{label}(\.{label})+", value))
|
return bool(re.fullmatch(rf"{label}(\.{label})+", value))
|
||||||
|
|
||||||
|
|
||||||
def dns_txt_contains(name: str, expected: str) -> bool:
|
|
||||||
try:
|
|
||||||
import dns.exception
|
|
||||||
import dns.resolver
|
|
||||||
|
|
||||||
answers = dns.resolver.resolve(name, "TXT", lifetime=5)
|
|
||||||
except dns.exception.DNSException:
|
|
||||||
return False
|
|
||||||
for answer in answers:
|
|
||||||
for item in answer.strings:
|
|
||||||
if item.decode("utf-8", errors="replace") == expected:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -12,10 +12,6 @@ DASHBOARD_PASSWORD_REMOVED = "dashboard_password_removed"
|
|||||||
DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated"
|
DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated"
|
||||||
DASHBOARD_ROUTER_CREDENTIALS_ROTATED = "dashboard_router_credentials_rotated"
|
DASHBOARD_ROUTER_CREDENTIALS_ROTATED = "dashboard_router_credentials_rotated"
|
||||||
DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted"
|
DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted"
|
||||||
DASHBOARD_DOMAIN_CLAIM_CREATED = "dashboard_domain_claim_created"
|
|
||||||
DASHBOARD_DOMAIN_CLAIM_VERIFIED = "dashboard_domain_claim_verified"
|
|
||||||
DASHBOARD_DOMAIN_CLAIM_PENDING = "dashboard_domain_claim_pending"
|
|
||||||
DASHBOARD_CUSTOM_HOSTNAME_CREATED = "dashboard_custom_hostname_created"
|
|
||||||
|
|
||||||
ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted"
|
ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted"
|
||||||
ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated"
|
ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated"
|
||||||
|
|||||||
@@ -39,14 +39,12 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
service,
|
service,
|
||||||
settings,
|
settings,
|
||||||
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
|
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
|
||||||
store.list_domain_challenges(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("domain_challenges", 0)),
|
|
||||||
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
|
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
|
||||||
store.list_scheduled_tasks(),
|
store.list_scheduled_tasks(),
|
||||||
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
|
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
|
||||||
store.get_service_metrics(),
|
store.get_service_metrics(),
|
||||||
{
|
{
|
||||||
"accounts": store.count_accounts(),
|
"accounts": store.count_accounts(),
|
||||||
"domain_challenges": store.count_domain_challenges(),
|
|
||||||
"cleanup_runs": store.count_cleanup_runs(),
|
"cleanup_runs": store.count_cleanup_runs(),
|
||||||
"update_events": store.count_update_events(),
|
"update_events": store.count_update_events(),
|
||||||
},
|
},
|
||||||
@@ -62,7 +60,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
_: None = Depends(authenticate_admin),
|
_: None = Depends(authenticate_admin),
|
||||||
accounts_offset: int = Query(0, ge=0),
|
accounts_offset: int = Query(0, ge=0),
|
||||||
domain_claims_offset: int = Query(0, ge=0),
|
|
||||||
cleanup_runs_offset: int = Query(0, ge=0),
|
cleanup_runs_offset: int = Query(0, ge=0),
|
||||||
update_events_offset: int = Query(0, ge=0),
|
update_events_offset: int = Query(0, ge=0),
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -72,7 +69,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
store,
|
store,
|
||||||
{
|
{
|
||||||
"accounts": accounts_offset,
|
"accounts": accounts_offset,
|
||||||
"domain_challenges": domain_claims_offset,
|
|
||||||
"cleanup_runs": cleanup_runs_offset,
|
"cleanup_runs": cleanup_runs_offset,
|
||||||
"update_events": update_events_offset,
|
"update_events": update_events_offset,
|
||||||
},
|
},
|
||||||
@@ -184,14 +180,12 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
|||||||
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
||||||
result = await run_in_threadpool(
|
result = await run_in_threadpool(
|
||||||
store.cleanup,
|
store.cleanup,
|
||||||
settings.cleanup_challenge_hours,
|
|
||||||
settings.cleanup_unused_account_hours,
|
settings.cleanup_unused_account_hours,
|
||||||
settings.cleanup_event_history_hours,
|
settings.cleanup_event_history_hours,
|
||||||
)
|
)
|
||||||
notice = _admin_secret_panel(
|
notice = _admin_secret_panel(
|
||||||
"Cleanup done",
|
"Cleanup done",
|
||||||
[
|
[
|
||||||
_copy_row("Domain claims removed", str(result["domain_challenges"])),
|
|
||||||
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
|
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
|
||||||
_copy_row("Old update events removed", str(result["update_events"])),
|
_copy_row("Old update events removed", str(result["update_events"])),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from ..ddns_dns import DnsProviderError
|
from ..ddns_dns import DnsProviderError
|
||||||
from ..ddns_service import BadIpError, normalize_domain
|
from ..ddns_service import BadIpError
|
||||||
from ..dependencies import ServiceDep, StoreDep
|
from ..dependencies import ServiceDep, StoreDep
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
CustomHostnameRequest,
|
|
||||||
DomainChallengeRequest,
|
|
||||||
DomainChallengeResponse,
|
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
HostnameCredentials,
|
HostnameCredentials,
|
||||||
MagicHostnameRequest,
|
MagicHostnameRequest,
|
||||||
ManagedRecord,
|
ManagedRecord,
|
||||||
RouterUpdateResponse,
|
RouterUpdateResponse,
|
||||||
VerifyDomainRequest,
|
|
||||||
VerifyDomainResponse,
|
|
||||||
_credentials_response,
|
_credentials_response,
|
||||||
_managed_record,
|
_managed_record,
|
||||||
)
|
)
|
||||||
@@ -75,71 +68,6 @@ async def delete_managed_hostname(management_slug: ManagementSlugPath, store: St
|
|||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/domains/challenges",
|
|
||||||
response_model=DomainChallengeResponse,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a DNS TXT domain verification challenge",
|
|
||||||
tags=["domains"],
|
|
||||||
responses={400: {"model": ErrorResponse}},
|
|
||||||
)
|
|
||||||
async def create_domain_challenge(payload: DomainChallengeRequest, service: ServiceDep) -> DomainChallengeResponse:
|
|
||||||
domain = normalize_domain(payload.domain)
|
|
||||||
try:
|
|
||||||
challenge = await run_in_threadpool(service.create_domain_challenge, domain)
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
|
||||||
return DomainChallengeResponse(
|
|
||||||
domain=domain,
|
|
||||||
txt_name=service.verification_name(domain),
|
|
||||||
txt_value=challenge["token"],
|
|
||||||
claim_secret=challenge["claim_secret"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/domains/verify",
|
|
||||||
response_model=VerifyDomainResponse,
|
|
||||||
summary="Verify a domain TXT challenge",
|
|
||||||
tags=["domains"],
|
|
||||||
responses={404: {"model": ErrorResponse}},
|
|
||||||
)
|
|
||||||
async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> VerifyDomainResponse:
|
|
||||||
domain = normalize_domain(payload.domain)
|
|
||||||
found, _challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret)
|
|
||||||
if not found:
|
|
||||||
return VerifyDomainResponse(
|
|
||||||
domain=domain,
|
|
||||||
verified=False,
|
|
||||||
message="TXT record not found yet",
|
|
||||||
claim_secret=payload.claim_secret,
|
|
||||||
)
|
|
||||||
return VerifyDomainResponse(
|
|
||||||
domain=domain,
|
|
||||||
verified=True,
|
|
||||||
message="Domain verified",
|
|
||||||
claim_secret=payload.claim_secret,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/hostnames/custom",
|
|
||||||
response_model=HostnameCredentials,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
summary="Create a hostname for a verified domain",
|
|
||||||
tags=["hostnames"],
|
|
||||||
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
|
|
||||||
)
|
|
||||||
async def create_custom_hostname(payload: CustomHostnameRequest, service: ServiceDep) -> HostnameCredentials:
|
|
||||||
account = await run_in_threadpool(
|
|
||||||
service.create_custom_account,
|
|
||||||
payload.hostname,
|
|
||||||
payload.claim_secret,
|
|
||||||
payload.username,
|
|
||||||
)
|
|
||||||
return _credentials_response(service, account)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/updates/{update_slug}",
|
"/updates/{update_slug}",
|
||||||
response_model=RouterUpdateResponse,
|
response_model=RouterUpdateResponse,
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ THEME_JS = """
|
|||||||
|
|
||||||
CLIPBOARD_JS = """
|
CLIPBOARD_JS = """
|
||||||
document.addEventListener("click", async (event) => {
|
document.addEventListener("click", async (event) => {
|
||||||
|
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||||
|
if (!menu.contains(event.target)) menu.removeAttribute("open");
|
||||||
|
});
|
||||||
|
|
||||||
const toggle = event.target.closest("[data-secret-toggle]");
|
const toggle = event.target.closest("[data-secret-toggle]");
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
const group = toggle.closest(".copy-field");
|
const group = toggle.closest(".copy-field");
|
||||||
@@ -84,13 +88,29 @@ document.addEventListener("click", async (event) => {
|
|||||||
if (!button) return;
|
if (!button) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(button.dataset.copy || "");
|
await navigator.clipboard.writeText(button.dataset.copy || "");
|
||||||
const old = button.textContent;
|
const label = button.querySelector("[data-copy-label]");
|
||||||
button.textContent = "Copied";
|
const oldLabel = label ? label.textContent : "";
|
||||||
|
button.classList.add("is-copied");
|
||||||
|
if (label) label.textContent = "Copied";
|
||||||
|
const menu = button.closest("details.copy-menu");
|
||||||
|
if (menu) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
button.textContent = old;
|
menu.removeAttribute("open");
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
button.classList.remove("is-copied");
|
||||||
|
if (label) label.textContent = oldLabel;
|
||||||
}, 1200);
|
}, 1200);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||||
|
menu.removeAttribute("open");
|
||||||
|
});
|
||||||
|
});
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from ..web.forms import first_form_value, request_form
|
|||||||
from ..web.html_utils import trusted
|
from ..web.html_utils import trusted
|
||||||
from ..web.templates import _page
|
from ..web.templates import _page
|
||||||
from ..web.templates_management import _render_management_page, _render_management_unlock_page
|
from ..web.templates_management import _render_management_page, _render_management_unlock_page
|
||||||
from . import management_domains, management_passwords
|
from . import management_passwords
|
||||||
from .management_shared import (
|
from .management_shared import (
|
||||||
get_active_management_account,
|
get_active_management_account,
|
||||||
get_active_management_account_async,
|
get_active_management_account_async,
|
||||||
@@ -30,7 +30,6 @@ from .path_params import ManagementSlugPath
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(management_passwords.router)
|
router.include_router(management_passwords.router)
|
||||||
router.include_router(management_domains.router)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from starlette.concurrency import run_in_threadpool
|
|
||||||
|
|
||||||
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
|
||||||
from ..ddns_service import normalize_domain
|
|
||||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
|
||||||
from ..event_statuses import (
|
|
||||||
DASHBOARD_CUSTOM_HOSTNAME_CREATED,
|
|
||||||
DASHBOARD_DOMAIN_CLAIM_CREATED,
|
|
||||||
DASHBOARD_DOMAIN_CLAIM_PENDING,
|
|
||||||
DASHBOARD_DOMAIN_CLAIM_VERIFIED,
|
|
||||||
)
|
|
||||||
from ..web.forms import first_form_value, request_form
|
|
||||||
from ..web.templates_public import _render_public_home
|
|
||||||
from .management_shared import (
|
|
||||||
get_active_management_account_async,
|
|
||||||
has_management_access,
|
|
||||||
locked_management_response,
|
|
||||||
render_management_page_async,
|
|
||||||
)
|
|
||||||
from .path_params import ClaimSecretPath, ManagementSlugPath
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/d/{claim_secret}", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def manage_domain_claim(claim_secret: ClaimSecretPath, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
|
|
||||||
challenge = await run_in_threadpool(store.get_domain_challenge_by_secret, claim_secret)
|
|
||||||
if not challenge:
|
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
|
||||||
return await run_in_threadpool(_render_public_home, service, settings, None, None, challenge)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m/{management_slug}/domains/request", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def request_dashboard_domain(
|
|
||||||
management_slug: ManagementSlugPath,
|
|
||||||
request: Request,
|
|
||||||
settings: SettingsDep,
|
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
|
||||||
) -> str:
|
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
|
||||||
if not has_management_access(request, 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"))
|
|
||||||
try:
|
|
||||||
challenge = await run_in_threadpool(
|
|
||||||
service.create_domain_challenge,
|
|
||||||
first_form_value(form, "domain"),
|
|
||||||
str(account["hostname"]),
|
|
||||||
)
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
|
||||||
await run_in_threadpool(
|
|
||||||
store.log_update_event,
|
|
||||||
str(account["hostname"]),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
DASHBOARD_DOMAIN_CLAIM_CREATED,
|
|
||||||
"domain claim created",
|
|
||||||
client_ip(request, settings),
|
|
||||||
)
|
|
||||||
return await render_management_page_async(
|
|
||||||
service,
|
|
||||||
settings,
|
|
||||||
management_slug,
|
|
||||||
account,
|
|
||||||
dashboard_csrf_token(management_slug, account),
|
|
||||||
message="Claim created. Add the TXT record, then verify it.",
|
|
||||||
challenge=challenge,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m/{management_slug}/domains/verify", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def verify_dashboard_domain(
|
|
||||||
management_slug: ManagementSlugPath,
|
|
||||||
request: Request,
|
|
||||||
settings: SettingsDep,
|
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
|
||||||
) -> str:
|
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
|
||||||
if not has_management_access(request, settings, management_slug, account):
|
|
||||||
return locked_management_response(management_slug, account)
|
|
||||||
form = await request_form(request, settings)
|
|
||||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
|
||||||
domain = normalize_domain(first_form_value(form, "domain"))
|
|
||||||
claim_secret = first_form_value(form, "claim_secret")
|
|
||||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, str(account["hostname"]))
|
|
||||||
message = "Domain verified. You can create a hostname now." if found else "TXT record not found yet. Try again in a few minutes."
|
|
||||||
await run_in_threadpool(
|
|
||||||
store.log_update_event,
|
|
||||||
str(account["hostname"]),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
DASHBOARD_DOMAIN_CLAIM_VERIFIED if found else DASHBOARD_DOMAIN_CLAIM_PENDING,
|
|
||||||
"domain claim verified" if found else "domain claim TXT not found",
|
|
||||||
client_ip(request, settings),
|
|
||||||
)
|
|
||||||
return await render_management_page_async(
|
|
||||||
service,
|
|
||||||
settings,
|
|
||||||
management_slug,
|
|
||||||
account,
|
|
||||||
dashboard_csrf_token(management_slug, account),
|
|
||||||
message=message,
|
|
||||||
challenge=challenge,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m/{management_slug}/domains/hostnames", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def create_dashboard_custom_hostname(
|
|
||||||
management_slug: ManagementSlugPath,
|
|
||||||
request: Request,
|
|
||||||
settings: SettingsDep,
|
|
||||||
store: StoreDep,
|
|
||||||
service: ServiceDep,
|
|
||||||
) -> str:
|
|
||||||
account = await get_active_management_account_async(store, management_slug)
|
|
||||||
if not has_management_access(request, 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"))
|
|
||||||
created = await run_in_threadpool(
|
|
||||||
service.create_custom_account,
|
|
||||||
first_form_value(form, "hostname"),
|
|
||||||
first_form_value(form, "claim_secret"),
|
|
||||||
None,
|
|
||||||
str(account["hostname"]),
|
|
||||||
)
|
|
||||||
await run_in_threadpool(
|
|
||||||
store.log_update_event,
|
|
||||||
str(account["hostname"]),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
DASHBOARD_CUSTOM_HOSTNAME_CREATED,
|
|
||||||
"custom hostname created",
|
|
||||||
client_ip(request, settings),
|
|
||||||
)
|
|
||||||
return await render_management_page_async(
|
|
||||||
service,
|
|
||||||
settings,
|
|
||||||
management_slug,
|
|
||||||
account,
|
|
||||||
dashboard_csrf_token(management_slug, account),
|
|
||||||
message="Hostname created. Copy these router settings now.",
|
|
||||||
created_account=created,
|
|
||||||
)
|
|
||||||
@@ -78,7 +78,6 @@ async def render_management_page_async(
|
|||||||
account: ManagementAccount,
|
account: ManagementAccount,
|
||||||
csrf_token: str,
|
csrf_token: str,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
challenge: dict[str, str | None] | None = None,
|
|
||||||
created_account: dict[str, str] | None = None,
|
created_account: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return await run_in_threadpool(
|
return await run_in_threadpool(
|
||||||
@@ -89,7 +88,6 @@ async def render_management_page_async(
|
|||||||
account,
|
account,
|
||||||
csrf_token,
|
csrf_token,
|
||||||
message,
|
message,
|
||||||
challenge,
|
|
||||||
created_account,
|
created_account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,3 @@ ManagementSlugPath = Annotated[
|
|||||||
description="Private dashboard URL value.",
|
description="Private dashboard URL value.",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
ClaimSecretPath = Annotated[
|
|
||||||
str,
|
|
||||||
Path(
|
|
||||||
min_length=TOKEN_MIN_LENGTH,
|
|
||||||
max_length=TOKEN_MAX_LENGTH,
|
|
||||||
pattern=TOKEN_URLSAFE_PATTERN,
|
|
||||||
description="Private domain claim value.",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
|
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from ..ddns_security import client_ip
|
from ..ddns_security import client_ip
|
||||||
from ..ddns_service import normalize_domain
|
|
||||||
from ..dependencies import ServiceDep, SettingsDep
|
from ..dependencies import ServiceDep, SettingsDep
|
||||||
from ..web.forms import first_form_value, request_form
|
from ..web.forms import first_form_value, request_form
|
||||||
from ..web.templates_public import _render_privacy_page, _render_public_home
|
from ..web.templates_public import _render_privacy_page, _render_public_home
|
||||||
@@ -32,45 +29,15 @@ async def magic_account(request: Request, settings: SettingsDep, service: Servic
|
|||||||
return _render_public_home(service, settings, account, None)
|
return _render_public_home(service, settings, account, None)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/request-domain", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def request_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
|
||||||
form = await request_form(request, settings)
|
|
||||||
try:
|
|
||||||
challenge = await run_in_threadpool(
|
|
||||||
service.create_domain_challenge,
|
|
||||||
first_form_value(form, "domain"),
|
|
||||||
)
|
|
||||||
except sqlite3.IntegrityError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
|
||||||
return _render_public_home(service, settings, None, None, challenge)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-domain", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def verify_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
|
||||||
form = await request_form(request, settings)
|
|
||||||
domain = normalize_domain(first_form_value(form, "domain"))
|
|
||||||
claim_secret = first_form_value(form, "claim_secret")
|
|
||||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
|
|
||||||
if not found:
|
|
||||||
message = "TXT record not found yet. Try again in a few minutes."
|
|
||||||
return _render_public_home(service, settings, None, message, challenge)
|
|
||||||
return _render_public_home(service, settings, None, "Domain verified. You can create a hostname now.", challenge)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/accounts", response_class=HTMLResponse, include_in_schema=False)
|
@router.post("/accounts", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def self_service_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
async def self_service_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
||||||
form = await request_form(request, settings)
|
form = await request_form(request, settings)
|
||||||
mode = first_form_value(form, "mode")
|
mode = first_form_value(form, "mode")
|
||||||
if mode == "managed":
|
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)
|
account = await run_in_threadpool(service.create_managed_account, None)
|
||||||
else:
|
|
||||||
claim_secret = first_form_value(form, "claim_secret")
|
|
||||||
account = await run_in_threadpool(
|
|
||||||
service.create_custom_account,
|
|
||||||
first_form_value(form, "hostname"),
|
|
||||||
claim_secret,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
return _render_public_home(service, settings, account, None)
|
return _render_public_home(service, settings, account, None)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +65,6 @@ def robots_txt(settings: SettingsDep) -> str:
|
|||||||
"User-agent: *",
|
"User-agent: *",
|
||||||
f"Disallow: {admin_path}/",
|
f"Disallow: {admin_path}/",
|
||||||
"Disallow: /m/",
|
"Disallow: /m/",
|
||||||
"Disallow: /d/",
|
|
||||||
"Disallow: /u/",
|
"Disallow: /u/",
|
||||||
"Disallow: /nic/",
|
"Disallow: /nic/",
|
||||||
"Disallow: /api/",
|
"Disallow: /api/",
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update
|
from ..dependencies import ServiceDep, StoreDep, apply_update_response, authenticate_update
|
||||||
from .path_params import UpdateSlugPath
|
from .path_params import UpdateSlugPath
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -25,13 +23,14 @@ def slug_update(
|
|||||||
account = store.get_account_by_slug(update_slug)
|
account = store.get_account_by_slug(update_slug)
|
||||||
if not account or account["disabled"]:
|
if not account or account["disabled"]:
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
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)
|
return apply_update_response(service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nic/update", response_class=PlainTextResponse, response_model=None)
|
@router.get("/nic/update", response_class=PlainTextResponse, response_model=None)
|
||||||
def update(
|
def update(
|
||||||
request: Request,
|
request: Request,
|
||||||
settings: SettingsDep,
|
|
||||||
store: StoreDep,
|
store: StoreDep,
|
||||||
service: ServiceDep,
|
service: ServiceDep,
|
||||||
credentials: tuple[str | None, str | None] = Depends(authenticate_update),
|
credentials: tuple[str | None, str | None] = Depends(authenticate_update),
|
||||||
@@ -45,17 +44,10 @@ def update(
|
|||||||
host = (hostname or domain or "").strip().lower().rstrip(".")
|
host = (hostname or domain or "").strip().lower().rstrip(".")
|
||||||
if not host:
|
if not host:
|
||||||
return PlainTextResponse("nohost", status_code=400)
|
return PlainTextResponse("nohost", status_code=400)
|
||||||
if settings.allowed_hosts and host not in settings.allowed_hosts:
|
if not service.hostname_is_managed(host):
|
||||||
return PlainTextResponse("nohost", status_code=403)
|
return PlainTextResponse("nohost", status_code=403)
|
||||||
supplied_user, supplied_token = credentials
|
supplied_user, supplied_token = credentials
|
||||||
account_ok = store.verify_account(host, supplied_user, supplied_token)
|
account_ok = store.verify_account(host, supplied_user, supplied_token)
|
||||||
global_secret_ok = (
|
if not account_ok:
|
||||||
bool(settings.allowed_hosts)
|
|
||||||
and host in settings.allowed_hosts
|
|
||||||
and bool(settings.shared_secret)
|
|
||||||
and bool(supplied_token)
|
|
||||||
and secrets.compare_digest(supplied_token, settings.shared_secret)
|
|
||||||
)
|
|
||||||
if not global_secret_ok and not account_ok:
|
|
||||||
return PlainTextResponse("badauth", status_code=401)
|
return PlainTextResponse("badauth", status_code=401)
|
||||||
return apply_update_response(service, request, host, myip, ipaddr, myipv6, ip6addr)
|
return apply_update_response(service, request, host, myip, ipaddr, myipv6, ip6addr)
|
||||||
@@ -33,41 +33,6 @@ class ManagedRecord(BaseModel):
|
|||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class DomainChallengeRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
domain: str = Field(examples=["example.net"])
|
|
||||||
|
|
||||||
|
|
||||||
class DomainChallengeResponse(BaseModel):
|
|
||||||
domain: str
|
|
||||||
txt_name: str
|
|
||||||
txt_value: str
|
|
||||||
claim_secret: str = Field(description="Secret value used to return to and verify this domain claim.")
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyDomainRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
domain: str = Field(examples=["example.net"])
|
|
||||||
claim_secret: str
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyDomainResponse(BaseModel):
|
|
||||||
domain: str
|
|
||||||
verified: bool
|
|
||||||
message: str
|
|
||||||
claim_secret: str = Field(description="Secret value used to continue this domain claim flow.")
|
|
||||||
|
|
||||||
|
|
||||||
class CustomHostnameRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
hostname: str = Field(examples=["home.example.net"])
|
|
||||||
claim_secret: str
|
|
||||||
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
|
|
||||||
|
|
||||||
|
|
||||||
class RouterUpdateResponse(BaseModel):
|
class RouterUpdateResponse(BaseModel):
|
||||||
status: str = Field(examples=["good"])
|
status: str = Field(examples=["good"])
|
||||||
hostname: str
|
hostname: str
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from sqlite3 import Connection
|
|||||||
from time import monotonic
|
from time import monotonic
|
||||||
|
|
||||||
from .accounts import AccountsMixin
|
from .accounts import AccountsMixin
|
||||||
from .domains import DomainsMixin
|
|
||||||
from .metrics import MetricsMixin
|
from .metrics import MetricsMixin
|
||||||
from .rate_limit import RateLimitCleanupMixin
|
from .rate_limit import RateLimitCleanupMixin
|
||||||
from .records import RecordsEventsMixin
|
from .records import RecordsEventsMixin
|
||||||
@@ -15,7 +14,7 @@ from .scheduler import SchedulerMixin
|
|||||||
from .schema import connect_database, initialize_schema
|
from .schema import connect_database, initialize_schema
|
||||||
|
|
||||||
|
|
||||||
class DdnsStore(AccountsMixin, DomainsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
|
class DdnsStore(AccountsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
|
||||||
def __init__(self, database_path: Path) -> None:
|
def __init__(self, database_path: Path) -> None:
|
||||||
self.database_path = database_path
|
self.database_path = database_path
|
||||||
self._connections = threading.local()
|
self._connections = threading.local()
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
import sqlite3
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from ..ddns_security import hash_lookup_token
|
|
||||||
from .types import DomainChallengeRow
|
|
||||||
|
|
||||||
|
|
||||||
class DomainsMixin:
|
|
||||||
def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> DomainChallengeRow:
|
|
||||||
token = f"ff-ddns-{secrets.token_urlsafe(24)}"
|
|
||||||
claim_secret = secrets.token_urlsafe(32)
|
|
||||||
claim_hash = hash_lookup_token(claim_secret)
|
|
||||||
now = datetime.now(UTC).isoformat()
|
|
||||||
with self.connect() as conn:
|
|
||||||
existing = conn.execute(
|
|
||||||
"SELECT verified_at, owner_hostname FROM domain_challenges WHERE domain = ?",
|
|
||||||
(domain,),
|
|
||||||
).fetchone()
|
|
||||||
if existing and existing[0]:
|
|
||||||
raise sqlite3.IntegrityError("domain is already verified")
|
|
||||||
if existing and existing[1] and existing[1] != owner_hostname:
|
|
||||||
raise sqlite3.IntegrityError("domain claim belongs to another dashboard")
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO domain_challenges(domain, token, claim_hash, owner_hostname, created_at, verified_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NULL)
|
|
||||||
ON CONFLICT(domain) DO UPDATE SET
|
|
||||||
token=excluded.token,
|
|
||||||
claim_hash=excluded.claim_hash,
|
|
||||||
owner_hostname=excluded.owner_hostname,
|
|
||||||
created_at=excluded.created_at,
|
|
||||||
verified_at=NULL
|
|
||||||
""",
|
|
||||||
(domain, token, claim_hash, owner_hostname, now),
|
|
||||||
)
|
|
||||||
return {"domain": domain, "token": token, "claim_secret": claim_secret}
|
|
||||||
|
|
||||||
def verify_domain_challenge(
|
|
||||||
self,
|
|
||||||
domain: str,
|
|
||||||
claim_secret: str,
|
|
||||||
owner_hostname: str | None = None,
|
|
||||||
) -> DomainChallengeRow | None:
|
|
||||||
now = datetime.now(UTC).isoformat()
|
|
||||||
claim_hash = hash_lookup_token(claim_secret)
|
|
||||||
with self.connect() as conn:
|
|
||||||
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ? AND claim_hash = ?"
|
|
||||||
params = [domain, claim_hash]
|
|
||||||
if owner_hostname is not None:
|
|
||||||
sql += " AND owner_hostname = ?"
|
|
||||||
params.append(owner_hostname)
|
|
||||||
challenge = conn.execute(sql, tuple(params)).fetchone()
|
|
||||||
if not challenge:
|
|
||||||
return None
|
|
||||||
update_sql = "UPDATE domain_challenges SET verified_at = ? WHERE domain = ? AND claim_hash = ?"
|
|
||||||
update_params = [now, domain, claim_hash]
|
|
||||||
if owner_hostname is not None:
|
|
||||||
update_sql += " AND owner_hostname = ?"
|
|
||||||
update_params.append(owner_hostname)
|
|
||||||
cursor = conn.execute(update_sql, tuple(update_params))
|
|
||||||
if cursor.rowcount != 1:
|
|
||||||
return None
|
|
||||||
result = dict(challenge)
|
|
||||||
result["verified_at"] = now
|
|
||||||
result["claim_secret"] = claim_secret
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_domain_challenge(
|
|
||||||
self,
|
|
||||||
domain: str,
|
|
||||||
claim_secret: str | None = None,
|
|
||||||
owner_hostname: str | None = None,
|
|
||||||
) -> DomainChallengeRow | None:
|
|
||||||
with self.connect() as conn:
|
|
||||||
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ?"
|
|
||||||
params = [domain]
|
|
||||||
if claim_secret is not None:
|
|
||||||
sql += " AND claim_hash = ?"
|
|
||||||
params.append(hash_lookup_token(claim_secret))
|
|
||||||
if owner_hostname is not None:
|
|
||||||
sql += " AND owner_hostname = ?"
|
|
||||||
params.append(owner_hostname)
|
|
||||||
challenge = conn.execute(sql, tuple(params)).fetchone()
|
|
||||||
return dict(challenge) if challenge else None
|
|
||||||
|
|
||||||
def get_domain_challenge_by_secret(self, claim_secret: str) -> DomainChallengeRow | None:
|
|
||||||
with self.connect() as conn:
|
|
||||||
challenge = conn.execute(
|
|
||||||
"SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE claim_hash = ?",
|
|
||||||
(hash_lookup_token(claim_secret),),
|
|
||||||
).fetchone()
|
|
||||||
if not challenge:
|
|
||||||
return None
|
|
||||||
result = dict(challenge)
|
|
||||||
result["claim_secret"] = claim_secret
|
|
||||||
return result
|
|
||||||
|
|
||||||
def list_domain_challenges(self, limit: int = 100, offset: int = 0) -> list[DomainChallengeRow]:
|
|
||||||
with self.connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT domain, owner_hostname, created_at, verified_at
|
|
||||||
FROM domain_challenges
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
""",
|
|
||||||
(limit, offset),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def count_domain_challenges(self) -> int:
|
|
||||||
with self.connect() as conn:
|
|
||||||
row = conn.execute("SELECT COUNT(*) FROM domain_challenges").fetchone()
|
|
||||||
return int(row[0])
|
|
||||||
|
|
||||||
def is_domain_verified(
|
|
||||||
self,
|
|
||||||
domain: str,
|
|
||||||
claim_secret: str | None = None,
|
|
||||||
owner_hostname: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
with self.connect() as conn:
|
|
||||||
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
|
|
||||||
params = [domain]
|
|
||||||
if claim_secret is not None:
|
|
||||||
sql += " AND claim_hash = ?"
|
|
||||||
params.append(hash_lookup_token(claim_secret))
|
|
||||||
if owner_hostname is not None:
|
|
||||||
sql += " AND owner_hostname = ?"
|
|
||||||
params.append(owner_hostname)
|
|
||||||
verified_at = conn.execute(sql, tuple(params)).fetchone()
|
|
||||||
return bool(verified_at and verified_at[0])
|
|
||||||
@@ -13,7 +13,7 @@ class RateLimitCleanupMixin:
|
|||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
|
SELECT created_at, unused_accounts_deleted
|
||||||
FROM cleanup_runs
|
FROM cleanup_runs
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
@@ -116,19 +116,13 @@ class RateLimitCleanupMixin:
|
|||||||
|
|
||||||
def cleanup(
|
def cleanup(
|
||||||
self,
|
self,
|
||||||
challenge_hours: int,
|
|
||||||
unused_account_hours: int,
|
unused_account_hours: int,
|
||||||
event_history_hours: int = 24 * 180,
|
event_history_hours: int = 24 * 180,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
self.flush_rate_limits()
|
self.flush_rate_limits()
|
||||||
challenge_cutoff = (datetime.now(UTC) - timedelta(hours=challenge_hours)).isoformat()
|
|
||||||
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
|
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
|
||||||
event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
|
event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
challenge_cursor = conn.execute(
|
|
||||||
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?",
|
|
||||||
(challenge_cutoff,),
|
|
||||||
)
|
|
||||||
account_cursor = conn.execute(
|
account_cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
DELETE FROM accounts
|
DELETE FROM accounts
|
||||||
@@ -151,13 +145,12 @@ class RateLimitCleanupMixin:
|
|||||||
conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 60,))
|
conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 60,))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO cleanup_runs(created_at, domain_challenges_deleted, unused_accounts_deleted)
|
INSERT INTO cleanup_runs(created_at, unused_accounts_deleted)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?)
|
||||||
""",
|
""",
|
||||||
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
|
(datetime.now(UTC).isoformat(), account_cursor.rowcount),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"domain_challenges": challenge_cursor.rowcount,
|
|
||||||
"unused_accounts": account_cursor.rowcount,
|
"unused_accounts": account_cursor.rowcount,
|
||||||
"event_rollups": rollup_cursor.rowcount,
|
"event_rollups": rollup_cursor.rowcount,
|
||||||
"update_events": event_cursor.rowcount,
|
"update_events": event_cursor.rowcount,
|
||||||
|
|||||||
@@ -90,11 +90,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
|||||||
CREATE TABLE IF NOT EXISTS cleanup_runs (
|
CREATE TABLE IF NOT EXISTS cleanup_runs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
domain_challenges_deleted INTEGER NOT NULL,
|
|
||||||
unused_accounts_deleted INTEGER NOT NULL
|
unused_accounts_deleted INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
_migrate_cleanup_runs_schema(conn)
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -167,31 +167,6 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_hostname_username ON accounts(hostname, username)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_hostname_username ON accounts(hostname, username)")
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS domain_challenges (
|
|
||||||
domain TEXT PRIMARY KEY,
|
|
||||||
token TEXT NOT NULL,
|
|
||||||
claim_hash TEXT,
|
|
||||||
owner_hostname TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
verified_at TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
|
|
||||||
_ensure_column(conn, "domain_challenges", "owner_hostname", "TEXT")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_owner_hostname ON domain_challenges(owner_hostname)")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_owner_verified ON domain_challenges(owner_hostname, verified_at)")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_claim_hash ON domain_challenges(claim_hash)")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_created_at ON domain_challenges(created_at)")
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_domain_challenges_unverified_created
|
|
||||||
ON domain_challenges(created_at)
|
|
||||||
WHERE verified_at IS NULL
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
|
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
|
||||||
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||||
conn.execute("PRAGMA optimize")
|
conn.execute("PRAGMA optimize")
|
||||||
@@ -201,3 +176,28 @@ def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition
|
|||||||
columns = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
columns = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||||
if column not in columns:
|
if column not in columns:
|
||||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||||
|
|
||||||
|
|
||||||
|
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"}:
|
||||||
|
return
|
||||||
|
conn.execute("DROP INDEX IF EXISTS idx_cleanup_runs_created_at")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE cleanup_runs_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
unused_accounts_deleted INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cleanup_runs_new(id, created_at, unused_accounts_deleted)
|
||||||
|
SELECT id, created_at, unused_accounts_deleted
|
||||||
|
FROM cleanup_runs
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("DROP TABLE cleanup_runs")
|
||||||
|
conn.execute("ALTER TABLE cleanup_runs_new RENAME TO cleanup_runs")
|
||||||
@@ -18,15 +18,6 @@ class AccountRow(TypedDict, total=False):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class DomainChallengeRow(TypedDict, total=False):
|
|
||||||
domain: str
|
|
||||||
token: str
|
|
||||||
claim_secret: str
|
|
||||||
owner_hostname: str | None
|
|
||||||
created_at: str
|
|
||||||
verified_at: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateEventRow(TypedDict, total=False):
|
class UpdateEventRow(TypedDict, total=False):
|
||||||
hostname: str
|
hostname: str
|
||||||
ipv4: str | None
|
ipv4: str | None
|
||||||
@@ -40,7 +31,6 @@ class UpdateEventRow(TypedDict, total=False):
|
|||||||
|
|
||||||
class CleanupRunRow(TypedDict):
|
class CleanupRunRow(TypedDict):
|
||||||
created_at: str
|
created_at: str
|
||||||
domain_challenges_deleted: int
|
|
||||||
unused_accounts_deleted: int
|
unused_accounts_deleted: int
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,11 +107,11 @@ def is_admin_path(path: str, settings: DdnsSettings) -> bool:
|
|||||||
def is_secret_response(request: Request) -> bool:
|
def is_secret_response(request: Request) -> bool:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
settings = request.app.state.settings
|
settings = request.app.state.settings
|
||||||
if is_admin_path(path, settings) or path.startswith(("/m/", "/d/", "/u/")):
|
if is_admin_path(path, settings) or path.startswith(("/m/", "/u/")):
|
||||||
return True
|
return True
|
||||||
if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
|
if path.startswith("/api/v1/hostnames/"):
|
||||||
return request.method in {"POST", "DELETE"}
|
return request.method in {"POST", "DELETE"}
|
||||||
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
|
return (request.method == "POST" and path in {"/magic", "/accounts"}) or path == "/nic/update"
|
||||||
|
|
||||||
|
|
||||||
def is_noindex_response(request: Request) -> bool:
|
def is_noindex_response(request: Request) -> bool:
|
||||||
@@ -120,7 +120,7 @@ def is_noindex_response(request: Request) -> bool:
|
|||||||
return True
|
return True
|
||||||
if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}:
|
if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}:
|
||||||
return True
|
return True
|
||||||
if path.startswith(("/api/", "/m/", "/d/", "/u/", "/nic/")):
|
if path.startswith(("/api/", "/m/", "/u/", "/nic/")):
|
||||||
return True
|
return True
|
||||||
return is_admin_path(path, request.app.state.settings)
|
return is_admin_path(path, request.app.state.settings)
|
||||||
|
|
||||||
@@ -154,7 +154,6 @@ def redacted_access_path(path: str) -> str:
|
|||||||
secret_prefixes = {
|
secret_prefixes = {
|
||||||
"/u": "/u/<secret>",
|
"/u": "/u/<secret>",
|
||||||
"/m": "/m/<secret>",
|
"/m": "/m/<secret>",
|
||||||
"/d": "/d/<secret>",
|
|
||||||
"/api/v1/updates": "/api/v1/updates/<secret>",
|
"/api/v1/updates": "/api/v1/updates/<secret>",
|
||||||
"/api/v1/management": "/api/v1/management/<secret>",
|
"/api/v1/management": "/api/v1/management/<secret>",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -544,17 +544,36 @@ select:focus-visible {
|
|||||||
background: var(--rp-field);
|
background: var(--rp-field);
|
||||||
}
|
}
|
||||||
|
|
||||||
.manual-code {
|
.copy-button {
|
||||||
overflow-x: auto;
|
display: inline-flex;
|
||||||
padding: 16px;
|
align-items: center;
|
||||||
border: 1px solid var(--rp-line);
|
justify-content: center;
|
||||||
border-radius: 12px;
|
gap: 6px;
|
||||||
color: var(--rp-terminal-ink);
|
}
|
||||||
background: var(--rp-terminal);
|
|
||||||
font: 400 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
.copy-button .copy-icon,
|
||||||
|
.copy-button .check-icon,
|
||||||
|
.copy-icon-button .copy-icon,
|
||||||
|
.copy-icon-button .check-icon,
|
||||||
|
.copy-menu-item .copy-icon,
|
||||||
|
.copy-menu-item .check-icon,
|
||||||
|
.copy-menu .more-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon,
|
||||||
|
.is-copied .copy-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-copied .check-icon {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-card {
|
.terminal-card {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
@@ -565,6 +584,14 @@ select:focus-visible {
|
|||||||
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.code-window {
|
||||||
|
padding-right: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-code {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-card code {
|
.terminal-card code {
|
||||||
display: block;
|
display: block;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -572,13 +599,151 @@ select:focus-visible {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copy-icon-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-icon-button:hover,
|
||||||
|
.copy-icon-button:focus-visible {
|
||||||
|
color: var(--rp-ink);
|
||||||
|
background: var(--rp-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-menu > summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--rp-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
background: var(--rp-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-menu > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-menu > summary:hover,
|
||||||
|
.copy-menu > summary:focus-visible {
|
||||||
|
color: var(--rp-ink);
|
||||||
|
background: var(--rp-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-menu-popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
display: grid;
|
||||||
|
min-width: 220px;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--rp-line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--rp-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-menu-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--rp-ink);
|
||||||
|
background: transparent;
|
||||||
|
font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-menu-item:hover,
|
||||||
|
.copy-menu-item:focus-visible {
|
||||||
|
background: var(--rp-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
padding: 32px 0;
|
padding: 28px 0;
|
||||||
background: var(--rp-canvas);
|
background: var(--rp-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-footer .nav-link:hover {
|
.footer-inner {
|
||||||
color: var(--rp-ink) !important;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-made {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--rp-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover,
|
||||||
|
.footer-link:focus-visible {
|
||||||
|
color: var(--rp-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:focus-visible {
|
||||||
|
border-radius: 999px;
|
||||||
|
outline: 2px solid var(--rp-focus-ring);
|
||||||
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
@@ -679,6 +844,30 @@ select:focus-visible {
|
|||||||
border-radius: 0 0 14px 14px;
|
border-radius: 0 0 14px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card .copy-icon-button,
|
||||||
|
.copy-icon-button {
|
||||||
|
width: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .copy-menu > summary,
|
||||||
|
.copy-menu > summary {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-menu-popover {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-inner {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ import json
|
|||||||
from .html_utils import SafeHtml, attr, escape, join_html, trusted
|
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:
|
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
|
||||||
account_html = ""
|
account_html = ""
|
||||||
if label:
|
if label:
|
||||||
@@ -63,16 +83,66 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtm
|
|||||||
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
|
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
|
||||||
{value_field}
|
{value_field}
|
||||||
{reveal_button}
|
{reveal_button}
|
||||||
<button type="button" class="btn btn-outline-secondary" data-copy="{safe_value}" aria-label="Copy {safe_label}">Copy</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>
|
</div>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _copy_button(label: str, value: str) -> SafeHtml:
|
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
|
||||||
safe_label = escape(label)
|
safe_label = escape(label)
|
||||||
safe_value = attr(value)
|
safe_value = attr(value)
|
||||||
return trusted(f'<button type="button" class="btn btn-outline-primary rounded-pill" data-copy="{safe_value}">{safe_label}</button>')
|
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:
|
def _manual_update_commands(update_url: str) -> str:
|
||||||
@@ -87,7 +157,6 @@ def _manual_update_commands(update_url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _manual_update_panel(commands: str) -> SafeHtml:
|
def _manual_update_panel(commands: str) -> SafeHtml:
|
||||||
safe_commands = escape(commands)
|
|
||||||
return trusted(
|
return trusted(
|
||||||
f"""
|
f"""
|
||||||
<div class="card card-body gap-3 mt-4">
|
<div class="card card-body gap-3 mt-4">
|
||||||
@@ -95,8 +164,7 @@ def _manual_update_panel(commands: str) -> SafeHtml:
|
|||||||
<h3>Manual updates</h3>
|
<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>
|
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
|
||||||
</div>
|
</div>
|
||||||
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
|
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
||||||
{_copy_button("Copy curl", commands)}
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -125,8 +193,8 @@ def _secret_notice() -> SafeHtml:
|
|||||||
return trusted(
|
return trusted(
|
||||||
"""
|
"""
|
||||||
<div class="alert alert-info my-3 py-2 small" role="note">
|
<div class="alert alert-info my-3 py-2 small" role="note">
|
||||||
<strong>Public:</strong> Domain name and CNAME target can be shared.
|
<strong>Public:</strong> Hostname and CNAME target can be shared.
|
||||||
<strong>Secret:</strong> Update URLs, router password, dashboard link, and claim links can update or manage this hostname.
|
<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.
|
Secrets are hidden on screen. Copy still copies the real value.
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@@ -159,15 +227,33 @@ def _site_footer() -> SafeHtml:
|
|||||||
return trusted(
|
return trusted(
|
||||||
"""
|
"""
|
||||||
<footer class="site-footer border-top">
|
<footer class="site-footer border-top">
|
||||||
<div class="container d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row">
|
<div class="container footer-inner">
|
||||||
<div class="small text-secondary">Made by Karl</div>
|
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
|
||||||
<nav class="nav justify-content-center gap-3" aria-label="Footer links">
|
<nav class="footer-links" aria-label="Footer links">
|
||||||
<a class="nav-link p-0 small text-secondary" href="https://github.com/StasonJatham/karldns.de" rel="noopener">GitHub</a>
|
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
|
||||||
<a class="nav-link p-0 small text-secondary" href="https://karl.fail" rel="noopener">karl.fail</a>
|
<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>
|
||||||
<a class="nav-link p-0 small text-secondary" href="https://karlcom.de" rel="noopener">karlcom.de</a>
|
<span>GitHub</span>
|
||||||
<a class="nav-link p-0 small text-secondary" href="/docs">API docs</a>
|
</a>
|
||||||
<a class="nav-link p-0 small text-secondary" href="/redoc">ReDoc</a>
|
<a class="footer-link" href="https://karl.fail" rel="noopener">
|
||||||
<a class="nav-link p-0 small text-secondary" href="/privacy">Legal & privacy</a>
|
<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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -242,10 +328,10 @@ def _page(
|
|||||||
<link rel="manifest" href="/site.webmanifest">
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
{preload_logo_html}
|
{preload_logo_html}
|
||||||
<script src="/theme.js?v=2"></script>
|
<script src="/theme.js?v=2"></script>
|
||||||
<script src="/clipboard.js?v=2" defer></script>
|
<script src="/clipboard.js?v=3" defer></script>
|
||||||
{extra_scripts_html}
|
{extra_scripts_html}
|
||||||
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
||||||
<link href="/app.css?v=2" rel="stylesheet">
|
<link href="/app.css?v=5" rel="stylesheet">
|
||||||
{extra_styles_html}
|
{extra_styles_html}
|
||||||
{structured_data_html}
|
{structured_data_html}
|
||||||
</head>
|
</head>
|
||||||
@@ -257,7 +343,10 @@ def _page(
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"SafeHtml",
|
"SafeHtml",
|
||||||
"_copy_button",
|
"_copy_button",
|
||||||
|
"_copy_icon_button",
|
||||||
|
"_copy_menu",
|
||||||
"_copy_row",
|
"_copy_row",
|
||||||
|
"_code_window",
|
||||||
"_fritzbox_fields",
|
"_fritzbox_fields",
|
||||||
"_manual_update_commands",
|
"_manual_update_commands",
|
||||||
"_manual_update_panel",
|
"_manual_update_panel",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from .templates_admin_tables import (
|
|||||||
_account_row,
|
_account_row,
|
||||||
_admin_table_pagination,
|
_admin_table_pagination,
|
||||||
_admin_table_toolbar,
|
_admin_table_toolbar,
|
||||||
_domain_claim_row,
|
|
||||||
_event_row,
|
_event_row,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@ def _render_admin_page(
|
|||||||
service: DdnsService,
|
service: DdnsService,
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
accounts: list[dict[str, str | int | None]],
|
accounts: list[dict[str, str | int | None]],
|
||||||
domain_challenges: list[dict[str, str | None]],
|
|
||||||
cleanup_runs: list[dict[str, str | int]],
|
cleanup_runs: list[dict[str, str | int]],
|
||||||
scheduled_tasks: list[dict[str, str | int | None]],
|
scheduled_tasks: list[dict[str, str | int | None]],
|
||||||
update_events: list[dict[str, str | int | None]],
|
update_events: list[dict[str, str | int | None]],
|
||||||
@@ -52,24 +50,13 @@ def _render_admin_page(
|
|||||||
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
|
||||||
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
|
<tr class="empty-row"><td colspan="7" class="empty">No hostnames yet.</td></tr>
|
||||||
"""
|
"""
|
||||||
domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """
|
|
||||||
<tr class="empty-row"><td colspan="5" class="empty">No domain claims yet.</td></tr>
|
|
||||||
"""
|
|
||||||
event_rows = "\n".join(_event_row(event) for event in update_events) or """
|
event_rows = "\n".join(_event_row(event) for event in update_events) or """
|
||||||
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
|
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
|
||||||
"""
|
"""
|
||||||
accounts_count = escape(table_counts.get("accounts", len(accounts)))
|
accounts_count = escape(table_counts.get("accounts", len(accounts)))
|
||||||
domain_count = escape(table_counts.get("domain_challenges", len(domain_challenges)))
|
|
||||||
event_count = escape(table_counts.get("update_events", len(update_events)))
|
event_count = escape(table_counts.get("update_events", len(update_events)))
|
||||||
metrics_html = _metrics_panel(service_metrics)
|
metrics_html = _metrics_panel(service_metrics)
|
||||||
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
|
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
|
||||||
domain_pager = _server_page_links(
|
|
||||||
admin_path,
|
|
||||||
"domain_claims_offset",
|
|
||||||
table_offsets.get("domain_challenges", 0),
|
|
||||||
table_limit,
|
|
||||||
table_counts.get("domain_challenges", 0),
|
|
||||||
)
|
|
||||||
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
|
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
|
||||||
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
|
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
|
||||||
return _page(
|
return _page(
|
||||||
@@ -81,7 +68,7 @@ def _render_admin_page(
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="eyebrow">KarlDNS</p>
|
<p class="eyebrow">KarlDNS</p>
|
||||||
<h1>Operator console</h1>
|
<h1>Operator console</h1>
|
||||||
<p class="lead">Review hostnames, domain checks, updates, cleanup, and secret changes.</p>
|
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -115,7 +102,7 @@ def _render_admin_page(
|
|||||||
<div class="table-responsive border rounded admin-table-scroll">
|
<div class="table-responsive border rounded admin-table-scroll">
|
||||||
<table class="table mb-0">
|
<table class="table mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th scope="col">Domain</th><th scope="col">User</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
|
<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>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{rows}</tbody>
|
<tbody>{rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -125,27 +112,6 @@ def _render_admin_page(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Domain claims</p>
|
|
||||||
<h2>Domain verification</h2>
|
|
||||||
<p class="section-copy mb-0">Showing {escape(len(domain_challenges))} of {domain_count}.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{_admin_table_toolbar("domain-claims", "Search domain claims")}
|
|
||||||
<div class="table-responsive border rounded admin-table-scroll">
|
|
||||||
<table class="table mb-0">
|
|
||||||
<thead><tr><th scope="col">Domain</th><th scope="col">Owner dashboard</th><th scope="col">Status</th><th scope="col">Created</th><th scope="col">Verified</th></tr></thead>
|
|
||||||
<tbody>{domain_rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{_admin_table_pagination()}
|
|
||||||
{domain_pager}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||||
@@ -158,7 +124,7 @@ def _render_admin_page(
|
|||||||
{_admin_table_toolbar("update-events", "Search update events")}
|
{_admin_table_toolbar("update-events", "Search update events")}
|
||||||
<div class="table-responsive border rounded admin-table-scroll">
|
<div class="table-responsive border rounded admin-table-scroll">
|
||||||
<table class="table mb-0">
|
<table class="table mb-0">
|
||||||
<thead><tr><th scope="col">Time</th><th scope="col">Domain</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
|
<thead><tr><th scope="col">Time</th><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
|
||||||
<tbody>{event_rows}</tbody>
|
<tbody>{event_rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +173,6 @@ def _cleanup_panel(
|
|||||||
last_error = task.get("last_error") or ""
|
last_error = task.get("last_error") or ""
|
||||||
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
|
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
|
||||||
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
||||||
domains_removed = last_cleanup.get("domain_challenges_deleted", 0)
|
|
||||||
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
|
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
|
||||||
error_html = (
|
error_html = (
|
||||||
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
|
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
|
||||||
@@ -237,7 +202,7 @@ def _cleanup_panel(
|
|||||||
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row">
|
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="h5 mb-2">Cleanup</h3>
|
<h3 class="h5 mb-2">Cleanup</h3>
|
||||||
<p class="section-copy mb-1">Last cleanup removed {escape(domains_removed)} domain claims and {escape(accounts_removed)} unused hostnames.</p>
|
<p class="section-copy mb-1">Last cleanup removed {escape(accounts_removed)} unused hostnames.</p>
|
||||||
{error_html}
|
{error_html}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
||||||
|
|||||||
@@ -66,32 +66,6 @@ def _admin_table_pagination() -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _domain_claim_row(challenge: dict[str, str | None]) -> str:
|
|
||||||
domain = escape(challenge["domain"])
|
|
||||||
owner = escape(challenge.get("owner_hostname") or "-")
|
|
||||||
created = escape(challenge.get("created_at") or "-")
|
|
||||||
verified = escape(challenge.get("verified_at") or "-")
|
|
||||||
status = "Verified" if challenge.get("verified_at") else "Pending"
|
|
||||||
return f"<tr><td>{domain}</td><td>{owner}</td><td>{status}</td><td>{created}</td><td>{verified}</td></tr>"
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_row(run: dict[str, str | int]) -> str:
|
|
||||||
created = escape(run.get("created_at") or "-")
|
|
||||||
domains = escape(run.get("domain_challenges_deleted") or 0)
|
|
||||||
accounts = escape(run.get("unused_accounts_deleted") or 0)
|
|
||||||
return f"<tr><td>{created}</td><td>{domains}</td><td>{accounts}</td></tr>"
|
|
||||||
|
|
||||||
|
|
||||||
def _scheduled_task_row(task: dict[str, str | int | None]) -> str:
|
|
||||||
name = escape(task.get("name") or "-")
|
|
||||||
status = escape(task.get("last_status") or "-")
|
|
||||||
next_run = escape(task.get("next_run_at") or "-")
|
|
||||||
lease_expires = escape(task.get("lease_expires_at") or "-")
|
|
||||||
last_finished = escape(task.get("last_finished_at") or "-")
|
|
||||||
last_error = escape(task.get("last_error") or "-")
|
|
||||||
return f"<tr><td>{name}</td><td>{status}</td><td>{next_run}</td><td>{lease_expires}</td><td>{last_finished}</td><td>{last_error}</td></tr>"
|
|
||||||
|
|
||||||
|
|
||||||
def _event_row(event: dict[str, str | int | None]) -> str:
|
def _event_row(event: dict[str, str | int | None]) -> str:
|
||||||
created = escape(event.get("created_at") or "-")
|
created = escape(event.get("created_at") or "-")
|
||||||
hostname = escape(event.get("hostname") or "-")
|
hostname = escape(event.get("hostname") or "-")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from ..ddns_models import DdnsSettings
|
|||||||
from ..ddns_service import DdnsService
|
from ..ddns_service import DdnsService
|
||||||
from .html_utils import SafeHtml, attr, escape, trusted
|
from .html_utils import SafeHtml, attr, escape, trusted
|
||||||
from .templates import (
|
from .templates import (
|
||||||
_copy_button,
|
_copy_menu,
|
||||||
_copy_row,
|
_copy_row,
|
||||||
_fritzbox_fields,
|
_fritzbox_fields,
|
||||||
_manual_update_commands,
|
_manual_update_commands,
|
||||||
@@ -24,7 +24,6 @@ def _render_management_page(
|
|||||||
account: dict[str, str | int | None],
|
account: dict[str, str | int | None],
|
||||||
csrf_token: str,
|
csrf_token: str,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
challenge: dict[str, str | None] | None = None,
|
|
||||||
created_account: dict[str, str] | None = None,
|
created_account: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
account_for_url = {
|
account_for_url = {
|
||||||
@@ -48,7 +47,6 @@ def _render_management_page(
|
|||||||
"""
|
"""
|
||||||
message_html = _management_message(message)
|
message_html = _management_message(message)
|
||||||
rotated_credentials = _credentials_panel(service, created_account)
|
rotated_credentials = _credentials_panel(service, created_account)
|
||||||
domain_claim = _management_domain_claim_panel(service, management_slug, csrf_token, challenge)
|
|
||||||
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
|
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS management",
|
"KarlDNS management",
|
||||||
@@ -71,12 +69,11 @@ def _render_management_page(
|
|||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<p class="eyebrow">Router settings</p>
|
<p class="eyebrow">Router settings</p>
|
||||||
<h2>Router update settings</h2>
|
<h2>Router update settings</h2>
|
||||||
<p class="section-copy">Use the Update-URL if your router has one URL field. Username and password are only for routers that require them. For your own subdomain, create a CNAME to this hostname.</p>
|
<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()}
|
{_one_time_secret_notice()}
|
||||||
{_secret_notice()}
|
{_secret_notice()}
|
||||||
<div class="d-flex gap-2 flex-wrap mt-3">
|
<div class="copy-actions mt-3">
|
||||||
{_copy_button("Copy router fields", fritzbox_fields)}
|
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])}
|
||||||
{_copy_button("Copy CNAME", cname_record)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6 d-grid gap-2">
|
<div class="col-lg-6 d-grid gap-2">
|
||||||
@@ -109,7 +106,6 @@ def _render_management_page(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{domain_claim}
|
|
||||||
{dashboard_password_panel}
|
{dashboard_password_panel}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
|
||||||
@@ -275,99 +271,6 @@ def _render_management_unlock_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _management_domain_claim_panel(
|
|
||||||
service: DdnsService,
|
|
||||||
management_slug: str,
|
|
||||||
csrf_token: str,
|
|
||||||
challenge: dict[str, str | None] | None,
|
|
||||||
) -> SafeHtml:
|
|
||||||
create_claim = trusted(
|
|
||||||
f"""
|
|
||||||
<div class="card card-body gap-3">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Custom domain</p>
|
|
||||||
<h2>Use your domain</h2>
|
|
||||||
<p class="section-copy">Create a TXT record, verify it here, then create a hostname under that domain.</p>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/m/{attr(management_slug)}/domains/request" class="d-grid gap-3" aria-label="Create dashboard domain claim">
|
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
|
||||||
<label>Domain
|
|
||||||
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Create claim</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if not challenge:
|
|
||||||
return trusted(
|
|
||||||
f"""
|
|
||||||
<section class="section">
|
|
||||||
<div class="container narrow">{create_claim}</div>
|
|
||||||
</section>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
domain = str(challenge["domain"])
|
|
||||||
claim_secret = str(challenge.get("claim_secret") or "")
|
|
||||||
verification_name = service.verification_name(domain)
|
|
||||||
token = str(challenge["token"])
|
|
||||||
claim_url = service.domain_claim_url(challenge)
|
|
||||||
status = "Verified" if challenge.get("verified_at") else "Waiting for TXT"
|
|
||||||
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
|
|
||||||
create_hostname = _management_custom_hostname_form(management_slug, claim_secret, csrf_token) if challenge.get("verified_at") else trusted("")
|
|
||||||
return trusted(
|
|
||||||
f"""
|
|
||||||
<section class="section" id="custom-domain">
|
|
||||||
<div class="container narrow d-grid gap-4">
|
|
||||||
{create_claim}
|
|
||||||
<div class="card card-body gap-3">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Domain claim</p>
|
|
||||||
<h2>{escape(domain)}</h2>
|
|
||||||
<p class="section-copy">Status: {escape(status)}</p>
|
|
||||||
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Other dashboards cannot use it.</p>
|
|
||||||
</div>
|
|
||||||
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. Claim secret and private claim link are secret.</div>
|
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
|
||||||
{_copy_button("Copy TXT", txt_bundle)}
|
|
||||||
</div>
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
{_copy_row("TXT name", verification_name, "Public")}
|
|
||||||
{_copy_row("TXT value", token, "Public")}
|
|
||||||
{_copy_row("Claim secret", claim_secret, "Secret")}
|
|
||||||
{_copy_row("Private claim link", claim_url, "Secret")}
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/m/{attr(management_slug)}/domains/verify" class="d-flex gap-2 flex-wrap" aria-label="Verify dashboard domain claim">
|
|
||||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
|
||||||
<input type="hidden" name="domain" value="{attr(domain)}">
|
|
||||||
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
|
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Verify</button>
|
|
||||||
</form>
|
|
||||||
{create_hostname}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _management_custom_hostname_form(management_slug: str, claim_secret: str, csrf_token: str | None = None) -> SafeHtml:
|
|
||||||
csrf_field = f'<input type="hidden" name="csrf" value="{attr(csrf_token)}">' if csrf_token else ""
|
|
||||||
return trusted(
|
|
||||||
f"""
|
|
||||||
<form method="post" action="/m/{attr(management_slug)}/domains/hostnames" class="d-grid gap-3" aria-label="Create verified custom hostname">
|
|
||||||
{csrf_field}
|
|
||||||
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
|
|
||||||
<label>Hostname
|
|
||||||
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
|
|
||||||
</form>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _user_event_row(event: dict[str, str | int | None]) -> str:
|
def _user_event_row(event: dict[str, str | int | None]) -> str:
|
||||||
created = escape(event.get("created_at") or "-")
|
created = escape(event.get("created_at") or "-")
|
||||||
status = escape(event.get("status") or "-")
|
status = escape(event.get("status") or "-")
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|||||||
from ..ddns_models import DdnsSettings
|
from ..ddns_models import DdnsSettings
|
||||||
from ..ddns_service import DdnsService
|
from ..ddns_service import DdnsService
|
||||||
from .html_utils import escape, trusted
|
from .html_utils import escape, trusted
|
||||||
from .templates import _message_band, _page, _top_nav
|
from .templates import _code_window, _message_band, _page, _top_nav
|
||||||
from .templates_public_sections import _credentials_panel, _custom_domain_flow, _linux_installer_section
|
from .templates_public_sections import _credentials_panel, _linux_installer_section
|
||||||
|
|
||||||
|
|
||||||
def _render_public_home(
|
def _render_public_home(
|
||||||
@@ -12,15 +12,24 @@ def _render_public_home(
|
|||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
created_account: dict[str, str] | None,
|
created_account: dict[str, str] | None,
|
||||||
message: str | None,
|
message: str | None,
|
||||||
challenge: dict[str, str | None] | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
base_url = settings.public_base_url.rstrip("/")
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
suffix = escape(settings.hostname_suffix or "your DynDNS domain")
|
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
|
||||||
|
suffix = escape(hostname_suffix)
|
||||||
demo_update_url = escape(f"{base_url}/u/<secret>?myip=<ipaddr>")
|
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)
|
created_html = _credentials_panel(service, created_account)
|
||||||
message_html = _message_band(message) if message and not challenge else ""
|
message_html = _message_band(message) if message else ""
|
||||||
challenge_html = _custom_domain_flow(service, challenge, message)
|
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
|
||||||
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, custom domains, Linux cron jobs, and home networks."
|
|
||||||
return _page(
|
return _page(
|
||||||
"KarlDNS",
|
"KarlDNS",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
@@ -33,7 +42,7 @@ def _render_public_home(
|
|||||||
<div class="install-pill mx-auto">
|
<div class="install-pill mx-auto">
|
||||||
<code>{demo_update_url}</code>
|
<code>{demo_update_url}</code>
|
||||||
</div>
|
</div>
|
||||||
<p class="lead">KarlDNS gives you a private DynDNS update URL under {suffix}. Add it to your router, or use it from a script.</p>
|
<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">
|
<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-primary rounded-pill" href="#create">Create hostname</a>
|
||||||
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
|
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
|
||||||
@@ -47,12 +56,12 @@ def _render_public_home(
|
|||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<p class="eyebrow">Hostname</p>
|
<p class="eyebrow">Hostname</p>
|
||||||
<h2>No account needed.</h2>
|
<h2>No account needed.</h2>
|
||||||
<p class="section-copy mx-auto">Use a KarlDNS hostname now, or verify your own domain when you need it.</p>
|
<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>
|
||||||
<div class="row g-4 align-items-stretch">
|
<div class="row g-4 align-items-stretch">
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card card-body h-100 gap-3">
|
<div class="card card-body h-100 gap-3">
|
||||||
<p class="eyebrow">KarlDNS domain</p>
|
<p class="eyebrow">KarlDNS hostname</p>
|
||||||
<h3>Create a hostname</h3>
|
<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>
|
<p class="section-copy mb-0">You get a hostname, a private update URL, and router fields if your router needs them.</p>
|
||||||
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
|
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
|
||||||
@@ -62,38 +71,25 @@ def _render_public_home(
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card card-body h-100 gap-3">
|
<div class="card card-body h-100 gap-3">
|
||||||
<p class="eyebrow">Use your own domain</p>
|
<p class="eyebrow">Optional DNS alias</p>
|
||||||
<h3>Verify your domain</h3>
|
<h3>Add a CNAME</h3>
|
||||||
<p class="section-copy mb-0">Add one TXT record first. KarlDNS only updates domains it can verify.</p>
|
<p class="section-copy mb-0">After creation, add a CNAME at your DNS provider:</p>
|
||||||
<form method="post" action="/request-domain" class="d-grid gap-3" aria-label="Request a custom domain TXT challenge">
|
{_code_window(cname_example, copy_label="CNAME example")}
|
||||||
<label>Domain
|
<p class="small text-secondary mb-0">KarlDNS updates only the generated hostname. The external DNS name follows it through CNAME.</p>
|
||||||
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Create TXT record</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-card mt-4" aria-hidden="true">
|
{_code_window(create_demo, copy_label="update example", class_name="mt-4")}
|
||||||
<code>$ curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'</code>
|
|
||||||
<code>good 203.0.113.10</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section" id="manual-updates">
|
<section class="section" id="manual-updates">
|
||||||
<div class="container narrow text-center">
|
<div class="container narrow text-center">
|
||||||
<h2>Update from a router or script.</h2>
|
<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>
|
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
|
||||||
<div class="terminal-card text-start mt-4">
|
{_code_window(manual_demo, copy_label="manual update examples", class_name="text-start mt-4")}
|
||||||
<code># explicit IPv4</code>
|
|
||||||
<code>curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'</code>
|
|
||||||
<code># source IP fallback</code>
|
|
||||||
<code>curl -sS 'https://karldns.de/u/<secret>'</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{_linux_installer_section()}
|
{_linux_installer_section()}
|
||||||
{challenge_html}
|
|
||||||
</main>
|
</main>
|
||||||
"""),
|
"""),
|
||||||
description=description,
|
description=description,
|
||||||
@@ -114,7 +110,7 @@ def _render_public_home(
|
|||||||
"featureList": [
|
"featureList": [
|
||||||
"Dynamic DNS for FRITZ!Box routers",
|
"Dynamic DNS for FRITZ!Box routers",
|
||||||
"Accountless private update URLs",
|
"Accountless private update URLs",
|
||||||
"Custom domain TXT verification",
|
"CNAME target for external DNS names",
|
||||||
"Linux cron updater",
|
"Linux cron updater",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -122,7 +118,7 @@ def _render_public_home(
|
|||||||
|
|
||||||
def _render_privacy_page(settings: DdnsSettings) -> str:
|
def _render_privacy_page(settings: DdnsSettings) -> str:
|
||||||
base_url = settings.public_base_url.rstrip("/")
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, DNS verification records, IP history, and cleanup behavior."
|
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
|
||||||
return _page(
|
return _page(
|
||||||
"Legal and Privacy",
|
"Legal and Privacy",
|
||||||
trusted(f"""
|
trusted(f"""
|
||||||
@@ -139,15 +135,15 @@ def _render_privacy_page(settings: DdnsSettings) -> str:
|
|||||||
<div class="container narrow d-grid gap-4">
|
<div class="container narrow d-grid gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2>Data processed</h2>
|
<h2>Data processed</h2>
|
||||||
<p class="section-copy">KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, TXT verification records, recent IP history, timestamps, and service events.</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h2>Secrets</h2>
|
<h2>Secrets</h2>
|
||||||
<p class="section-copy">Dashboard links, update URLs, router passwords, full update URLs, and claim links are secrets. Anyone with them may update or manage the hostname. Passwords and dashboard lookup values are not stored as readable text.</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h2>Logs and retention</h2>
|
<h2>Logs and retention</h2>
|
||||||
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes abandoned setup records and unused hostnames. Server logs may also include IP addresses and request details.</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ..ddns_service import DdnsService
|
from ..ddns_service import DdnsService
|
||||||
from .html_utils import SafeHtml, attr, escape, trusted
|
from .html_utils import SafeHtml, escape, trusted
|
||||||
from .templates import (
|
from .templates import (
|
||||||
_copy_button,
|
_code_window,
|
||||||
|
_copy_menu,
|
||||||
_copy_row,
|
_copy_row,
|
||||||
_fritzbox_fields,
|
_fritzbox_fields,
|
||||||
_manual_update_commands,
|
_manual_update_commands,
|
||||||
@@ -18,7 +19,6 @@ def _linux_installer_section() -> SafeHtml:
|
|||||||
system_uninstall = "curl -fsSL https://karldns.de/uninstall.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"
|
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"
|
clean_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sh -s -- --mode user --delete-config"
|
||||||
all_commands = "\n".join([system_install, system_uninstall, user_install, clean_uninstall])
|
|
||||||
return trusted(
|
return trusted(
|
||||||
f"""
|
f"""
|
||||||
<section class="section" id="linux-installer">
|
<section class="section" id="linux-installer">
|
||||||
@@ -29,15 +29,16 @@ def _linux_installer_section() -> SafeHtml:
|
|||||||
<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>
|
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates a hostname, installs the updater, schedules it, and prints your private dashboard link.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-body gap-3">
|
<div class="card card-body gap-3">
|
||||||
<div class="terminal-card">
|
<div>
|
||||||
<code>{escape(system_install)}</code>
|
<h3>Install</h3>
|
||||||
<code>{escape(system_uninstall)}</code>
|
<p class="section-copy">System install for most Linux servers.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
{_code_window(system_install, copy_label="install command")}
|
||||||
{_copy_button("Copy install", system_install)}
|
<div>
|
||||||
{_copy_button("Copy uninstall", system_uninstall)}
|
<h3>Uninstall</h3>
|
||||||
{_copy_button("Copy all commands", all_commands)}
|
<p class="section-copy">Stops the updater and leaves your private URLs on disk.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{_code_window(system_uninstall, copy_label="uninstall command")}
|
||||||
<div class="alert alert-info my-0 py-2 small" role="note">
|
<div class="alert alert-info my-0 py-2 small" role="note">
|
||||||
Uninstall keeps your private URLs by default. Use <code>--delete-config</code> only when you want to remove everything.
|
Uninstall keeps your private URLs by default. Use <code>--delete-config</code> only when you want to remove everything.
|
||||||
</div>
|
</div>
|
||||||
@@ -45,67 +46,8 @@ def _linux_installer_section() -> SafeHtml:
|
|||||||
<h3>User install</h3>
|
<h3>User install</h3>
|
||||||
<p class="section-copy">Use this when you do not want a system-wide install.</p>
|
<p class="section-copy">Use this when you do not want a system-wide install.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-card">
|
{_code_window(user_install, copy_label="user install command")}
|
||||||
<code>{escape(user_install)}</code>
|
{_copy_menu("More copy options", [("Clean user uninstall", clean_uninstall)])}
|
||||||
<code>{escape(clean_uninstall)}</code>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 flex-wrap">
|
|
||||||
{_copy_button("Copy user install", user_install)}
|
|
||||||
{_copy_button("Copy clean user uninstall", clean_uninstall)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> SafeHtml:
|
|
||||||
if not challenge:
|
|
||||||
return trusted("")
|
|
||||||
|
|
||||||
domain = attr(challenge["domain"])
|
|
||||||
token = str(challenge["token"])
|
|
||||||
claim_secret = attr(challenge.get("claim_secret") or "")
|
|
||||||
verification_name = service.verification_name(str(challenge["domain"]))
|
|
||||||
claim_url = service.domain_claim_url(challenge)
|
|
||||||
status = escape(message or "Add this TXT record at your DNS provider, then check again. DNS can take a few minutes.")
|
|
||||||
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
|
|
||||||
challenge_html = trusted(
|
|
||||||
f"""
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<div class="card card-body h-100 gap-3">
|
|
||||||
<span class="badge text-bg-primary rounded-circle align-self-start">1</span>
|
|
||||||
<h3>Add the TXT record</h3>
|
|
||||||
<p class="small text-secondary mb-0 mt-2">{status}</p>
|
|
||||||
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. The private claim link is secret.</div>
|
|
||||||
{_copy_button("Copy TXT", txt_bundle)}
|
|
||||||
{_copy_row("TXT name", verification_name, "Public")}
|
|
||||||
{_copy_row("TXT value", token, "Public")}
|
|
||||||
{_copy_row("Private claim link", claim_url, "Secret")}
|
|
||||||
<form method="post" action="/verify-domain" class="mt-3" aria-label="Check DNS TXT verification">
|
|
||||||
<input type="hidden" name="domain" value="{domain}">
|
|
||||||
<input type="hidden" name="claim_secret" value="{claim_secret}">
|
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Check DNS record</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
create_form = _custom_hostname_form(claim_secret) if challenge.get("verified_at") else _locked_custom_hostname_card()
|
|
||||||
|
|
||||||
return trusted(
|
|
||||||
f"""
|
|
||||||
<section class="section" id="custom-domain">
|
|
||||||
<div class="container narrow">
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="eyebrow">Custom domain</p>
|
|
||||||
<h2>Verify your domain</h2>
|
|
||||||
<p class="section-copy mx-auto">Use this when KarlDNS should update records inside your domain. For a simpler setup, create a KarlDNS hostname and point a CNAME to it.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 mt-4 justify-content-center">
|
|
||||||
{challenge_html}
|
|
||||||
{create_form}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -134,10 +76,8 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
|
|||||||
<p class="section-copy">Use the Update-URL directly. Username and password are only for routers that require separate fields.</p>
|
<p class="section-copy">Use the Update-URL directly. Username and password are only for routers that require separate fields.</p>
|
||||||
{_one_time_secret_notice()}
|
{_one_time_secret_notice()}
|
||||||
{_secret_notice()}
|
{_secret_notice()}
|
||||||
<div class="d-flex gap-2 flex-wrap my-3">
|
<div class="copy-actions my-3">
|
||||||
{_copy_button("Copy router fields", fritzbox_fields)}
|
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Full update URL", credentialed_update_url), ("CNAME", cname_record)])}
|
||||||
{_copy_button("Copy full URL", credentialed_update_url)}
|
|
||||||
{_copy_button("Copy CNAME", cname_record)}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||||
@@ -153,39 +93,3 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
|
|||||||
</section>
|
</section>
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _custom_hostname_form(claim_secret: str) -> SafeHtml:
|
|
||||||
return trusted(
|
|
||||||
f"""
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<div class="card card-body h-100 gap-3">
|
|
||||||
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
|
|
||||||
<h3>Create hostname</h3>
|
|
||||||
<form method="post" action="/accounts" class="d-grid gap-3" aria-label="Create custom-domain router credentials">
|
|
||||||
<input type="hidden" name="mode" value="custom">
|
|
||||||
<input type="hidden" name="claim_secret" value="{claim_secret}">
|
|
||||||
<label>Verified hostname
|
|
||||||
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
|
||||||
</label>
|
|
||||||
<p class="section-copy mb-0">Router fields are created after this step.</p>
|
|
||||||
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _locked_custom_hostname_card() -> SafeHtml:
|
|
||||||
return trusted(
|
|
||||||
"""
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<div class="card card-body h-100 gap-3 opacity-75">
|
|
||||||
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
|
|
||||||
<h3>Create hostname</h3>
|
|
||||||
<p class="small text-secondary mb-0 mt-2">Available after KarlDNS can see the TXT record.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
@@ -6,20 +6,8 @@ from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
|||||||
from router_dyndns.ddns_security import admin_csrf_token
|
from router_dyndns.ddns_security import admin_csrf_token
|
||||||
|
|
||||||
|
|
||||||
def test_shared_secret_is_not_admin_password_fallback(tmp_path: Path) -> None:
|
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
response = client.get("/admin", auth=("admin", "secret"))
|
|
||||||
|
|
||||||
assert response.status_code == 500
|
|
||||||
assert response.json()["detail"] == "DDNS_ADMIN_PASSWORD is required"
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
|
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
store = DdnsStore(database_path)
|
|
||||||
store.create_domain_challenge("example.net")
|
|
||||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
@@ -32,10 +20,10 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
|
|||||||
assert "Operator console" in response.text
|
assert "Operator console" in response.text
|
||||||
assert "Generate credentials" not in response.text
|
assert "Generate credentials" not in response.text
|
||||||
assert 'action="/admin/accounts"' not in response.text
|
assert 'action="/admin/accounts"' not in response.text
|
||||||
assert response.text.count("data-admin-table") == 3
|
assert response.text.count("data-admin-table") == 2
|
||||||
assert response.text.count("data-table-search") == 3
|
assert response.text.count("data-table-search") == 2
|
||||||
assert response.text.count("data-table-page-size") == 3
|
assert response.text.count("data-table-page-size") == 2
|
||||||
assert response.text.count("data-table-pagination") == 3
|
assert response.text.count("data-table-pagination") == 2
|
||||||
assert "admin-table-scroll" in response.text
|
assert "admin-table-scroll" in response.text
|
||||||
assert "Cleanup" in response.text
|
assert "Cleanup" in response.text
|
||||||
assert "Run now" in response.text
|
assert "Run now" in response.text
|
||||||
@@ -43,9 +31,7 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
|
|||||||
assert "Metrics" in response.text
|
assert "Metrics" in response.text
|
||||||
assert "Blocked requests" in response.text
|
assert "Blocked requests" in response.text
|
||||||
assert "Hostnames" in response.text
|
assert "Hostnames" in response.text
|
||||||
assert "Domain claims" in response.text
|
|
||||||
assert "Update events" in response.text
|
assert "Update events" in response.text
|
||||||
assert "example.net" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
|
def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -63,11 +63,8 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
|||||||
assert "/events" not in schema["paths"]
|
assert "/events" not in schema["paths"]
|
||||||
assert "/" not in schema["paths"]
|
assert "/" not in schema["paths"]
|
||||||
assert "/magic" not in schema["paths"]
|
assert "/magic" not in schema["paths"]
|
||||||
assert "/request-domain" not in schema["paths"]
|
|
||||||
assert "/verify-domain" not in schema["paths"]
|
|
||||||
assert "/accounts" not in schema["paths"]
|
assert "/accounts" not in schema["paths"]
|
||||||
assert "/m/{management_slug}" not in schema["paths"]
|
assert "/m/{management_slug}" not in schema["paths"]
|
||||||
assert "/d/{claim_secret}" not in schema["paths"]
|
|
||||||
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
||||||
assert all(tag["name"] != "admin" for tag in schema["tags"])
|
assert all(tag["name"] != "admin" for tag in schema["tags"])
|
||||||
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
|
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
|
||||||
@@ -113,9 +110,6 @@ def test_public_redoc_only_exposes_api_and_router_endpoints(tmp_path: Path) -> N
|
|||||||
schema = client.get("/openapi.json").json()
|
schema = client.get("/openapi.json").json()
|
||||||
|
|
||||||
expected_public_paths = {
|
expected_public_paths = {
|
||||||
"/api/v1/domains/challenges",
|
|
||||||
"/api/v1/domains/verify",
|
|
||||||
"/api/v1/hostnames/custom",
|
|
||||||
"/api/v1/hostnames/magic",
|
"/api/v1/hostnames/magic",
|
||||||
"/api/v1/management/{management_slug}",
|
"/api/v1/management/{management_slug}",
|
||||||
"/api/v1/updates/{update_slug}",
|
"/api/v1/updates/{update_slug}",
|
||||||
@@ -144,16 +138,8 @@ def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None:
|
|||||||
("path", "payload"),
|
("path", "payload"),
|
||||||
[
|
[
|
||||||
("/api/v1/hostnames/magic", {"username": "router", "unexpected": True}),
|
("/api/v1/hostnames/magic", {"username": "router", "unexpected": True}),
|
||||||
("/api/v1/domains/challenges", {"domain": "example.net", "unexpected": True}),
|
|
||||||
("/api/v1/domains/verify", {"domain": "example.net", "claim_secret": "secret", "unexpected": True}),
|
|
||||||
(
|
|
||||||
"/api/v1/hostnames/custom",
|
|
||||||
{"hostname": "home.example.net", "claim_secret": "secret", "unexpected": True},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
|
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
@@ -235,50 +221,6 @@ def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypa
|
|||||||
assert store.list_update_events()[0]["status"] == "dns_failed"
|
assert store.list_update_events()[0]["status"] == "dns_failed"
|
||||||
|
|
||||||
|
|
||||||
def test_api_custom_hostname_requires_verified_domain(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
|
||||||
assert challenge.status_code == 201
|
|
||||||
challenge_body = challenge.json()
|
|
||||||
|
|
||||||
verified = client.post(
|
|
||||||
"/api/v1/domains/verify",
|
|
||||||
json={"domain": "example.net", "claim_secret": challenge_body["claim_secret"]},
|
|
||||||
)
|
|
||||||
assert verified.status_code == 200
|
|
||||||
assert verified.json()["verified"] is True
|
|
||||||
|
|
||||||
hostname = client.post(
|
|
||||||
"/api/v1/hostnames/custom",
|
|
||||||
json={
|
|
||||||
"hostname": "home.example.net",
|
|
||||||
"claim_secret": challenge_body["claim_secret"],
|
|
||||||
"username": "router",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert hostname.status_code == 201
|
|
||||||
assert hostname.json()["hostname"] == "home.example.net"
|
|
||||||
|
|
||||||
|
|
||||||
def test_domain_challenge_verification_requires_current_secret_and_owner(tmp_path: Path) -> None:
|
|
||||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
|
||||||
first = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
|
|
||||||
second = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
|
|
||||||
|
|
||||||
assert store.verify_domain_challenge("example.net", first["claim_secret"], owner_hostname="owner.example.net") is None
|
|
||||||
assert store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="other.example.net") is None
|
|
||||||
assert store.get_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")["verified_at"] is None
|
|
||||||
|
|
||||||
verified = store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")
|
|
||||||
|
|
||||||
assert verified is not None
|
|
||||||
assert verified["verified_at"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
|
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
@@ -309,47 +251,3 @@ def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None:
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
|
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
|
||||||
assert store.list_records() == []
|
assert store.list_records() == []
|
||||||
|
|
||||||
|
|
||||||
def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
|
||||||
verified = client.post(
|
|
||||||
"/api/v1/domains/verify",
|
|
||||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
|
||||||
)
|
|
||||||
overwrite = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
|
||||||
|
|
||||||
assert verified.status_code == 200
|
|
||||||
assert overwrite.status_code == 409
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
|
||||||
app = make_app(
|
|
||||||
DdnsSettings(
|
|
||||||
database_path=tmp_path / "ddns.sqlite3",
|
|
||||||
admin_password="admin",
|
|
||||||
dns_provider="cloudflare",
|
|
||||||
cloudflare_api_token="token",
|
|
||||||
cloudflare_zone_id="zone",
|
|
||||||
dns_zones={"ddns.example.net"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
|
||||||
client.post(
|
|
||||||
"/api/v1/domains/verify",
|
|
||||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
|
||||||
)
|
|
||||||
hostname = client.post(
|
|
||||||
"/api/v1/hostnames/custom",
|
|
||||||
json={"hostname": "home.example.net", "claim_secret": challenge["claim_secret"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert hostname.status_code == 403
|
|
||||||
assert hostname.json()["detail"] == "hostname is outside configured DNS publishing zones"
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -57,7 +58,7 @@ def test_cleanup_deletes_old_rate_limit_minute_buckets(tmp_path: Path) -> None:
|
|||||||
("recent", current_window - 30, 1),
|
("recent", current_window - 30, 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
store.cleanup(challenge_hours=1, unused_account_hours=1)
|
store.cleanup(unused_account_hours=1)
|
||||||
|
|
||||||
with store.connect() as conn:
|
with store.connect() as conn:
|
||||||
keys = {row[0] for row in conn.execute("SELECT key FROM rate_limits")}
|
keys = {row[0] for row in conn.execute("SELECT key FROM rate_limits")}
|
||||||
@@ -100,7 +101,6 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
|
|||||||
assert {
|
assert {
|
||||||
"accounts",
|
"accounts",
|
||||||
"cleanup_runs",
|
"cleanup_runs",
|
||||||
"domain_challenges",
|
|
||||||
"rate_limits",
|
"rate_limits",
|
||||||
"records",
|
"records",
|
||||||
"scheduled_tasks",
|
"scheduled_tasks",
|
||||||
@@ -111,6 +111,56 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
|
|||||||
assert store.list_scheduled_tasks()[0]["name"] == "cleanup"
|
assert store.list_scheduled_tasks()[0]["name"] == "cleanup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_migrates_legacy_cleanup_run_columns(tmp_path: Path) -> None:
|
||||||
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
|
with sqlite3.connect(database_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE cleanup_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
domain_challenges_deleted INTEGER NOT NULL,
|
||||||
|
abandoned_setups_deleted INTEGER NOT NULL,
|
||||||
|
unused_accounts_deleted INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cleanup_runs(
|
||||||
|
created_at,
|
||||||
|
domain_challenges_deleted,
|
||||||
|
abandoned_setups_deleted,
|
||||||
|
unused_accounts_deleted
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
("2026-01-01T00:00:00+00:00", 3, 2, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
store = DdnsStore(database_path)
|
||||||
|
|
||||||
|
with store.connect() as conn:
|
||||||
|
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT unused_accounts_deleted
|
||||||
|
FROM cleanup_runs
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
assert columns == {"id", "created_at", "unused_accounts_deleted"}
|
||||||
|
assert tuple(row) == (1,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_records_only_unused_hostname_count(tmp_path: Path) -> None:
|
||||||
|
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||||
|
|
||||||
|
result = store.cleanup(unused_account_hours=1)
|
||||||
|
|
||||||
|
assert result["unused_accounts"] == 0
|
||||||
|
assert set(store.list_cleanup_runs()[0]) == {"created_at", "unused_accounts_deleted"}
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
|
|
||||||
@@ -147,8 +197,6 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
|||||||
assert user_version == 1
|
assert user_version == 1
|
||||||
assert {
|
assert {
|
||||||
"idx_accounts_created_at",
|
"idx_accounts_created_at",
|
||||||
"idx_domain_challenges_claim_hash",
|
|
||||||
"idx_domain_challenges_unverified_created",
|
|
||||||
"idx_rate_limits_window",
|
"idx_rate_limits_window",
|
||||||
"idx_update_events_hostname_created_at",
|
"idx_update_events_hostname_created_at",
|
||||||
"idx_update_event_rollups_hostname_last_seen_at",
|
"idx_update_event_rollups_hostname_last_seen_at",
|
||||||
@@ -193,20 +241,19 @@ def test_cleanup_prunes_old_raw_update_events(tmp_path: Path) -> None:
|
|||||||
("2000-01-01T00:00:00+00:00", "old.example.net"),
|
("2000-01-01T00:00:00+00:00", "old.example.net"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = store.cleanup(challenge_hours=1, unused_account_hours=1, event_history_hours=24)
|
result = store.cleanup(unused_account_hours=1, event_history_hours=24)
|
||||||
|
|
||||||
assert result["update_events"] == 1
|
assert result["update_events"] == 1
|
||||||
events = store.list_update_events()
|
events = store.list_update_events()
|
||||||
assert [event["hostname"] for event in events] == ["new.example.net"]
|
assert [event["hostname"] for event in events] == ["new.example.net"]
|
||||||
|
|
||||||
|
|
||||||
def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
|
def test_background_cleanup_removes_abandoned_hostnames(tmp_path: Path) -> None:
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
database_path=database_path,
|
database_path=database_path,
|
||||||
admin_password="admin",
|
admin_password="admin",
|
||||||
cleanup_challenge_hours=1,
|
|
||||||
cleanup_unused_account_hours=1,
|
cleanup_unused_account_hours=1,
|
||||||
cleanup_interval_seconds=1,
|
cleanup_interval_seconds=1,
|
||||||
)
|
)
|
||||||
@@ -214,26 +261,17 @@ def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> Non
|
|||||||
store = DdnsStore(database_path)
|
store = DdnsStore(database_path)
|
||||||
|
|
||||||
with TestClient(app):
|
with TestClient(app):
|
||||||
abandoned_claim = store.create_domain_challenge("abandoned.example.net")
|
|
||||||
verified_claim = store.create_domain_challenge("verified.example.net")
|
|
||||||
abandoned_account = store.create_account("abandoned.ddns.example.net", "router")
|
abandoned_account = store.create_account("abandoned.ddns.example.net", "router")
|
||||||
active_account = store.create_account("active.ddns.example.net", "router")
|
active_account = store.create_account("active.ddns.example.net", "router")
|
||||||
store.upsert("active.ddns.example.net", "203.0.113.10", None)
|
store.upsert("active.ddns.example.net", "203.0.113.10", None)
|
||||||
|
|
||||||
old = "2000-01-01T00:00:00+00:00"
|
old = "2000-01-01T00:00:00+00:00"
|
||||||
with store.connect() as conn:
|
with store.connect() as conn:
|
||||||
conn.execute("UPDATE domain_challenges SET created_at = ? WHERE domain = ?", (old, abandoned_claim["domain"]))
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE domain_challenges SET created_at = ?, verified_at = ? WHERE domain = ?",
|
|
||||||
(old, old, verified_claim["domain"]),
|
|
||||||
)
|
|
||||||
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"]))
|
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"]))
|
||||||
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"]))
|
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"]))
|
||||||
|
|
||||||
time.sleep(1.3)
|
time.sleep(1.3)
|
||||||
|
|
||||||
assert store.get_domain_challenge("abandoned.example.net") is None
|
|
||||||
assert store.get_domain_challenge("verified.example.net") is not None
|
|
||||||
assert store.get_account_by_slug(abandoned_account["update_slug"]) is None
|
assert store.get_account_by_slug(abandoned_account["update_slug"]) is None
|
||||||
assert store.get_account_by_slug(active_account["update_slug"]) is not None
|
assert store.get_account_by_slug(active_account["update_slug"]) is not None
|
||||||
scheduled_task = store.list_scheduled_tasks()[0]
|
scheduled_task = store.list_scheduled_tasks()[0]
|
||||||
@@ -248,7 +286,7 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
|
|||||||
store = DdnsStore(database_path)
|
store = DdnsStore(database_path)
|
||||||
store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
|
store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
|
||||||
|
|
||||||
def fail_cleanup(challenge_hours, unused_account_hours, event_history_hours):
|
def fail_cleanup(unused_account_hours, event_history_hours):
|
||||||
raise RuntimeError("cleanup exploded")
|
raise RuntimeError("cleanup exploded")
|
||||||
|
|
||||||
caplog.set_level(logging.ERROR, logger="router_dyndns.app")
|
caplog.set_level(logging.ERROR, logger="router_dyndns.app")
|
||||||
|
|||||||
@@ -51,22 +51,6 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_shared_secret_without_allowed_hosts_cannot_update_arbitrary_hostname(tmp_path: Path) -> None:
|
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
|
||||||
store = DdnsStore(database_path)
|
|
||||||
app = make_app(DdnsSettings(database_path=database_path, shared_secret="secret", admin_password="admin"))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
response = client.get(
|
|
||||||
"/nic/update",
|
|
||||||
params={"hostname": "other.example.net", "myip": "203.0.113.9", "password": "secret"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.text == "badauth"
|
|
||||||
assert store.list_records() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
@@ -121,7 +105,6 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path
|
|||||||
"/api/v1/updates/short",
|
"/api/v1/updates/short",
|
||||||
"/m/short",
|
"/m/short",
|
||||||
"/api/v1/management/short",
|
"/api/v1/management/short",
|
||||||
"/d/short",
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
|
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import router_dyndns.ddns_service as ddns_service
|
|
||||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||||
|
|
||||||
|
|
||||||
@@ -32,8 +31,8 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
|||||||
assert "CNAME target:" in response.text
|
assert "CNAME target:" in response.text
|
||||||
assert "Benutzername (optional):" in response.text
|
assert "Benutzername (optional):" in response.text
|
||||||
assert "Kennwort (optional):" in response.text
|
assert "Kennwort (optional):" in response.text
|
||||||
assert "Copy router fields" in response.text
|
assert "Router fields" in response.text
|
||||||
assert "Copy CNAME" in response.text
|
assert "CNAME" in response.text
|
||||||
assert "Manual updates" in response.text
|
assert "Manual updates" in response.text
|
||||||
assert "curl -sS" in response.text
|
assert "curl -sS" in response.text
|
||||||
assert "203.0.113.10" in response.text
|
assert "203.0.113.10" in response.text
|
||||||
@@ -46,6 +45,23 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
|||||||
assert "/m/" in response.text
|
assert "/m/" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> 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("/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 app.state.store.count_accounts() == 0
|
||||||
|
|
||||||
|
|
||||||
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
|
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
@@ -66,10 +82,10 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
|
|||||||
assert "Benutzername (optional):" in response.text
|
assert "Benutzername (optional):" in response.text
|
||||||
assert "Kennwort (optional):" in response.text
|
assert "Kennwort (optional):" in response.text
|
||||||
assert "Private dashboard:" in response.text
|
assert "Private dashboard:" in response.text
|
||||||
assert "Copy router fields" in response.text
|
assert "Router fields" in response.text
|
||||||
assert "Copy CNAME" in response.text
|
assert "CNAME" in response.text
|
||||||
assert "FRITZ!Box fills in" in response.text
|
assert "FRITZ!Box fills in" in response.text
|
||||||
assert "Copy curl" in response.text
|
assert "Copy manual update commands" in response.text
|
||||||
assert response.text.count("Secret") >= 3
|
assert response.text.count("Secret") >= 3
|
||||||
assert response.text.count("Public") >= 3
|
assert response.text.count("Public") >= 3
|
||||||
assert ".ddns.example.net" in response.text
|
assert ".ddns.example.net" in response.text
|
||||||
@@ -99,8 +115,8 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
|||||||
assert "CNAME target:" in management.text
|
assert "CNAME target:" in management.text
|
||||||
assert "Benutzername (optional):" in management.text
|
assert "Benutzername (optional):" in management.text
|
||||||
assert "Kennwort (optional):" in management.text
|
assert "Kennwort (optional):" in management.text
|
||||||
assert "Copy router fields" in management.text
|
assert "Router fields" in management.text
|
||||||
assert "Copy CNAME" in management.text
|
assert "CNAME" in management.text
|
||||||
assert "Manual updates" in management.text
|
assert "Manual updates" in management.text
|
||||||
assert "curl -sS" in management.text
|
assert "curl -sS" in management.text
|
||||||
assert "Public:" in management.text
|
assert "Public:" in management.text
|
||||||
@@ -391,14 +407,11 @@ def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_pat
|
|||||||
|
|
||||||
rotate = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
|
rotate = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
|
||||||
delete = client.post(f"/m/{account['management_slug']}/delete")
|
delete = client.post(f"/m/{account['management_slug']}/delete")
|
||||||
claim = client.post(f"/m/{account['management_slug']}/domains/request", data={"domain": "example.net"})
|
|
||||||
|
|
||||||
assert rotate.status_code == 401
|
assert rotate.status_code == 401
|
||||||
assert delete.status_code == 401
|
assert delete.status_code == 401
|
||||||
assert claim.status_code == 401
|
|
||||||
assert store.get_account_by_hostname("locked.ddns.example.net")["update_slug"] == account["update_slug"]
|
assert store.get_account_by_hostname("locked.ddns.example.net")["update_slug"] == account["update_slug"]
|
||||||
assert store.get_account_by_slug(account["update_slug"]) is not None
|
assert store.get_account_by_slug(account["update_slug"]) is not None
|
||||||
assert store.get_domain_challenge("example.net") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
|
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
|
||||||
@@ -418,157 +431,6 @@ def test_management_page_shows_ip_history(tmp_path: Path) -> None:
|
|||||||
assert "203.0.113.10" in response.text
|
assert "203.0.113.10" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
|
||||||
store = DdnsStore(database_path)
|
|
||||||
account = store.create_account("base.ddns.example.net", "router")
|
|
||||||
app = make_app(
|
|
||||||
DdnsSettings(
|
|
||||||
database_path=database_path,
|
|
||||||
admin_password="admin",
|
|
||||||
public_base_url="http://ddns.example.net",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
dashboard = client.get(f"/m/{account['management_slug']}")
|
|
||||||
challenge = client.post(f"/m/{account['management_slug']}/domains/request", data={"csrf": _csrf(dashboard.text), "domain": "example.net"})
|
|
||||||
assert challenge.status_code == 200
|
|
||||||
assert "Claim created" in challenge.text
|
|
||||||
assert "TXT name" in challenge.text
|
|
||||||
assert "TXT value" in challenge.text
|
|
||||||
assert "Claim secret" in challenge.text
|
|
||||||
assert "Private claim link" in challenge.text
|
|
||||||
assert "This claim belongs to this dashboard" in challenge.text
|
|
||||||
assert "data-secret-toggle" in challenge.text
|
|
||||||
|
|
||||||
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
|
|
||||||
verified = client.post(
|
|
||||||
f"/m/{account['management_slug']}/domains/verify",
|
|
||||||
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
|
|
||||||
)
|
|
||||||
assert verified.status_code == 200
|
|
||||||
assert "Domain verified" in verified.text
|
|
||||||
assert "Create hostname" in verified.text
|
|
||||||
|
|
||||||
created = client.post(
|
|
||||||
f"/m/{account['management_slug']}/domains/hostnames",
|
|
||||||
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
|
|
||||||
)
|
|
||||||
assert created.status_code == 200
|
|
||||||
assert "Hostname created" in created.text
|
|
||||||
assert "home.example.net" in created.text
|
|
||||||
assert "Update-URL:" in created.text
|
|
||||||
assert "Private dashboard:" in created.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_domain_claims_are_bound_to_creating_dashboard(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
|
||||||
store = DdnsStore(database_path)
|
|
||||||
owner = store.create_account("owner.ddns.example.net", "owner")
|
|
||||||
other = store.create_account("other.ddns.example.net", "other")
|
|
||||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
owner_dashboard = client.get(f"/m/{owner['management_slug']}")
|
|
||||||
challenge = client.post(f"/m/{owner['management_slug']}/domains/request", data={"csrf": _csrf(owner_dashboard.text), "domain": "example.net"})
|
|
||||||
assert challenge.status_code == 200
|
|
||||||
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
|
|
||||||
|
|
||||||
verify_other = client.post(
|
|
||||||
f"/m/{other['management_slug']}/domains/verify",
|
|
||||||
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "domain": "example.net", "claim_secret": claim_secret},
|
|
||||||
)
|
|
||||||
create_other = client.post(
|
|
||||||
f"/m/{other['management_slug']}/domains/hostnames",
|
|
||||||
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "hostname": "home.example.net", "claim_secret": claim_secret},
|
|
||||||
)
|
|
||||||
verify_owner = client.post(
|
|
||||||
f"/m/{owner['management_slug']}/domains/verify",
|
|
||||||
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert verify_other.status_code == 404
|
|
||||||
assert create_other.status_code == 403
|
|
||||||
assert verify_owner.status_code == 200
|
|
||||||
assert "Domain verified" in verify_owner.text
|
|
||||||
assert store.get_account_by_hostname("home.example.net") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
|
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
|
||||||
app = make_app(
|
|
||||||
DdnsSettings(
|
|
||||||
database_path=tmp_path / "ddns.sqlite3",
|
|
||||||
admin_password="admin",
|
|
||||||
public_base_url="http://ddns.example.net",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
|
||||||
challenge_data = store.create_domain_challenge("example.net")
|
|
||||||
verify = client.post(
|
|
||||||
"/verify-domain",
|
|
||||||
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
|
|
||||||
)
|
|
||||||
assert verify.status_code == 200
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/accounts",
|
|
||||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "home.example.net" in response.text
|
|
||||||
assert "Update-URL:" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
|
||||||
app = make_app(
|
|
||||||
DdnsSettings(
|
|
||||||
database_path=tmp_path / "ddns.sqlite3",
|
|
||||||
admin_password="admin",
|
|
||||||
public_base_url="http://ddns.example.net",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
challenge = client.post("/request-domain", data={"domain": "example.net"})
|
|
||||||
assert challenge.status_code == 200
|
|
||||||
assert "Private claim link" in challenge.text
|
|
||||||
assert "/d/" in challenge.text
|
|
||||||
|
|
||||||
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
|
|
||||||
claim_page = client.get(f"/d/{claim_secret}")
|
|
||||||
assert claim_page.status_code == 200
|
|
||||||
assert "TXT value" in claim_page.text
|
|
||||||
|
|
||||||
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
|
|
||||||
assert verify.status_code == 200
|
|
||||||
assert "Create hostname" in verify.text
|
|
||||||
|
|
||||||
created = client.post(
|
|
||||||
"/accounts",
|
|
||||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
|
|
||||||
)
|
|
||||||
assert created.status_code == 200
|
|
||||||
assert "home.example.net" in created.text
|
|
||||||
assert "Update-URL:" in created.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
|
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
@@ -586,6 +448,7 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
|
|||||||
assert "Router login name" not in response.text
|
assert "Router login name" not in response.text
|
||||||
assert 'name="username"' not in response.text
|
assert 'name="username"' not in response.text
|
||||||
assert "You get a hostname, a private update URL, and router fields if your router needs them." 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 "Linux cron updater" in response.text
|
||||||
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
|
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
|
||||||
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
|
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
|
||||||
@@ -619,10 +482,10 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert '<script src="/theme.js?v=2"></script>' in response.text
|
assert '<script src="/theme.js?v=2"></script>' in response.text
|
||||||
assert '<script src="/clipboard.js?v=2" defer></script>' in response.text
|
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
|
||||||
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text
|
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text
|
||||||
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
|
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
|
||||||
assert '<link href="/app.css?v=2" rel="stylesheet">' in response.text
|
assert '<link href="/app.css?v=5" rel="stylesheet">' in response.text
|
||||||
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
|
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
|
||||||
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
|
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
|
||||||
assert "<style>" not in response.text
|
assert "<style>" not in response.text
|
||||||
@@ -668,7 +531,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
|||||||
assert 'name="twitter:card" content="summary_large_image"' in response.text
|
assert 'name="twitter:card" content="summary_large_image"' in response.text
|
||||||
assert 'application/ld+json' in response.text
|
assert 'application/ld+json' in response.text
|
||||||
assert "NetworkingApplication" in response.text
|
assert "NetworkingApplication" in response.text
|
||||||
assert "Made by Karl" in response.text
|
assert "Made with" in response.text
|
||||||
|
assert "❤️" in response.text
|
||||||
|
assert "by karl" in response.text
|
||||||
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
|
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
|
||||||
assert 'href="https://karl.fail"' in response.text
|
assert 'href="https://karl.fail"' in response.text
|
||||||
assert 'href="https://karlcom.de"' in response.text
|
assert 'href="https://karlcom.de"' in response.text
|
||||||
@@ -682,7 +547,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
|||||||
assert "Legal and privacy" in privacy.text
|
assert "Legal and privacy" in privacy.text
|
||||||
assert "Data processed" in privacy.text
|
assert "Data processed" in privacy.text
|
||||||
assert "protected router passwords" in privacy.text
|
assert "protected router passwords" in privacy.text
|
||||||
assert "Made by Karl" in privacy.text
|
assert "Made with" in privacy.text
|
||||||
|
assert "❤️" in privacy.text
|
||||||
|
assert "by karl" in privacy.text
|
||||||
|
|
||||||
|
|
||||||
def test_public_robots_and_sitemap_are_available(tmp_path: Path) -> None:
|
def test_public_robots_and_sitemap_are_available(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -10,23 +10,20 @@ from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsRec
|
|||||||
|
|
||||||
|
|
||||||
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
DdnsSettings(
|
store = DdnsStore(database_path)
|
||||||
database_path=tmp_path / "ddns.sqlite3",
|
account = store.create_account("home.ddns.example.net", "router")
|
||||||
shared_secret="secret",
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
|
||||||
admin_password="admin",
|
|
||||||
allowed_hosts={"home.example.net"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/nic/update",
|
"/nic/update",
|
||||||
params={
|
params={
|
||||||
"hostname": "home.example.net",
|
"hostname": "home.ddns.example.net",
|
||||||
"myip": "203.0.113.8",
|
"myip": "203.0.113.8",
|
||||||
"myipv6": "2001:db8::8",
|
"myipv6": "2001:db8::8",
|
||||||
"password": "secret",
|
"username": account["username"],
|
||||||
|
"password": account["password"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,36 +31,36 @@ def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
|||||||
assert response.text == "good 203.0.113.8"
|
assert response.text == "good 203.0.113.8"
|
||||||
records = client.get("/records", auth=("admin", "admin")).json()
|
records = client.get("/records", auth=("admin", "admin")).json()
|
||||||
assert records["total"] == 1
|
assert records["total"] == 1
|
||||||
assert records["items"][0]["hostname"] == "home.example.net"
|
assert records["items"][0]["hostname"] == "home.ddns.example.net"
|
||||||
assert records["items"][0]["ipv4"] == "203.0.113.8"
|
assert records["items"][0]["ipv4"] == "203.0.113.8"
|
||||||
assert records["items"][0]["ipv6"] == "2001:db8::8"
|
assert records["items"][0]["ipv6"] == "2001:db8::8"
|
||||||
|
|
||||||
|
|
||||||
def test_update_rejects_wrong_secret(tmp_path: Path) -> None:
|
def test_update_rejects_wrong_password(tmp_path: Path) -> None:
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
|
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", hostname_suffix="ddns.example.net"))
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/nic/update",
|
"/nic/update",
|
||||||
params={"hostname": "home.example.net", "myip": "203.0.113.8", "password": "wrong"},
|
params={"hostname": account["hostname"], "myip": "203.0.113.8", "username": account["username"], "password": "wrong"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_update_rejects_unlisted_host(tmp_path: Path) -> None:
|
def test_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
DdnsSettings(
|
store = DdnsStore(database_path)
|
||||||
database_path=tmp_path / "ddns.sqlite3",
|
account = store.create_account("home.example.net", "router")
|
||||||
shared_secret="secret",
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
|
||||||
allowed_hosts={"home.example.net"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/nic/update",
|
"/nic/update",
|
||||||
params={"hostname": "other.example.net", "myip": "203.0.113.8", "password": "secret"},
|
params={"hostname": account["hostname"], "myip": "203.0.113.8", "username": account["username"], "password": account["password"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -229,24 +226,17 @@ def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Pat
|
|||||||
assert published == []
|
assert published == []
|
||||||
|
|
||||||
|
|
||||||
def test_shared_secret_still_updates_explicitly_allowed_hostname(tmp_path: Path) -> None:
|
def test_slug_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
DdnsSettings(
|
store = DdnsStore(database_path)
|
||||||
database_path=tmp_path / "ddns.sqlite3",
|
account = store.create_account("home.example.net", "router")
|
||||||
shared_secret="secret",
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
|
||||||
admin_password="admin",
|
|
||||||
allowed_hosts={"home.example.net"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||||
"/nic/update",
|
|
||||||
params={"hostname": "home.example.net", "myip": "203.0.113.9", "password": "secret"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 404
|
||||||
assert response.text == "good 203.0.113.9"
|
assert store.list_records() == []
|
||||||
|
|
||||||
|
|
||||||
def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
|
def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
|
||||||
@@ -326,19 +316,18 @@ def test_wrong_family_legacy_ip_is_badip_and_preserves_records(tmp_path: Path) -
|
|||||||
def test_nic_update_invalid_ip_returns_badip(tmp_path: Path) -> None:
|
def test_nic_update_invalid_ip_returns_badip(tmp_path: Path) -> None:
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
store = DdnsStore(database_path)
|
store = DdnsStore(database_path)
|
||||||
app = make_app(
|
account = store.create_account("home.ddns.example.net", "router")
|
||||||
DdnsSettings(
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
|
||||||
database_path=database_path,
|
|
||||||
shared_secret="secret",
|
|
||||||
admin_password="admin",
|
|
||||||
allowed_hosts={"home.example.net"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/nic/update",
|
"/nic/update",
|
||||||
params={"hostname": "home.example.net", "myipv6": "203.0.113.9", "password": "secret"},
|
params={
|
||||||
|
"hostname": account["hostname"],
|
||||||
|
"myipv6": "203.0.113.9",
|
||||||
|
"username": account["username"],
|
||||||
|
"password": account["password"],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|||||||