diff --git a/.env.example b/.env.example index 7febcde..34bb30c 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,6 @@ DDNS_TTL=60 # DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20 # DDNS_MAX_REQUEST_BODY_BYTES=16384 # DDNS_REDACTED_ACCESS_LOG=0 -# DDNS_CLEANUP_CHALLENGE_HOURS=72 # DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS=1080 # DDNS_CLEANUP_EVENT_HISTORY_HOURS=4320 # DDNS_CLEANUP_INTERVAL_SECONDS=3600 diff --git a/README.md b/README.md index eca81c8..f596c59 100644 --- a/README.md +++ b/README.md @@ -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.** -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. @@ -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. - **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. -- **No registration required**: provider hostnames and custom-domain setup both use private magic links. -- **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. +- **No registration required**: generated hostnames use private magic links, with no accounts or login flow. +- **CNAME alias support**: users create one KarlDNS hostname, then point `home.example.com` to it at their own DNS provider. - **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support. - **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform. - **API first**: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under `/api/v1`. @@ -25,10 +25,6 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control ![Generated FRITZ!Box DynDNS credentials](docs/screenshots/generated-credentials.png) -### Verify a Custom Domain - -![DNS TXT verification flow for custom domains](docs/screenshots/domain-verification.png) - ## Logo And Social Preview 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: 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. 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. @@ -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. - Free DDNS service for friends, customers, a community, or a home lab. - 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. - Lightweight Cloudflare DDNS provider with a web UI. - 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 providers | Cloudflare API and RFC 2136 dynamic DNS | | 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 | | 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 | ## Quick Start @@ -109,7 +104,6 @@ Open: - ReDoc: `http://localhost:8080/redoc` 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. @@ -146,7 +140,7 @@ export DDNS_RFC2136_KEY_SECRET='replace-with-tsig-secret' 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 @@ -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: ```text -https://karldns.de/nic/update?hostname=&myip=&myipv6=&username=&password= +https://karldns.de/nic/update?hostname=&myip=&myipv6=&username=&password= ``` Prefer `/u/` for managed service use because the router request cannot change the hostname. @@ -198,7 +192,7 @@ curl -sS 'https://karldns.de/u/' The `/nic/update` compatibility endpoint also works for clients that expect hostname and credentials in the URL: ```bash -curl -sS 'https://karldns.de/nic/update?hostname=home.example.net&username=router&password=&myip=203.0.113.10' +curl -sS 'https://karldns.de/nic/update?hostname=a1b2c3d4.karldns.de&username=router&password=&myip=203.0.113.10' ``` ### 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 -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 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. -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 - -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. +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. ## 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. | | `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. | -| `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. | 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_REDACTED_ACCESS_LOG` | `0` | Enable KarlDNS request logs with bearer paths and update query strings redacted. | | `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. | -| `DDNS_CLEANUP_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_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. | @@ -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/schemas.py` | Pydantic request and response models used by the JSON API. | | `router_dyndns/web/` | HTML rendering, form parsing, and security-header/body-limit support code. | -| `router_dyndns/ddns_service.py` | Domain logic for account creation, 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/ddns_store.py` | Compatibility export for `DdnsStore`. | | `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 -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. diff --git a/docs/screenshots/domain-verification.png b/docs/screenshots/domain-verification.png deleted file mode 100644 index 1bbde8a..0000000 Binary files a/docs/screenshots/domain-verification.png and /dev/null differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png index bb0b280..440d372 100644 Binary files a/docs/screenshots/home.png and b/docs/screenshots/home.png differ diff --git a/router_dyndns/app.py b/router_dyndns/app.py index cbc3008..b5b32a4 100644 --- a/router_dyndns/app.py +++ b/router_dyndns/app.py @@ -34,11 +34,7 @@ __all__ = [ OPENAPI_TAGS = [ { "name": "hostnames", - "description": "Create KarlDNS hostnames or hostnames under verified domains.", - }, - { - "name": "domains", - "description": "Issue and verify DNS TXT challenges for custom domains.", + "description": "Create KarlDNS hostnames that can be used directly or as CNAME targets.", }, { "name": "updates", @@ -84,8 +80,8 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: title="KarlDNS", summary="A small self-hosted DynDNS provider for FRITZ!Box routers.", description=( - "Generate cryptographically random DynDNS endpoints, verify custom domains with DNS TXT " - "records, and publish A/AAAA records through Cloudflare or RFC 2136." + "Generate cryptographically random DynDNS endpoints and publish A/AAAA records " + "for KarlDNS-managed hostnames through Cloudflare or RFC 2136." ), version="0.1.0", docs_url="/docs", @@ -137,7 +133,6 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None: try: await run_in_threadpool( store.cleanup, - settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours, settings.cleanup_event_history_hours, ) diff --git a/router_dyndns/assets/social-preview.png b/router_dyndns/assets/social-preview.png index b4a1732..564029d 100644 Binary files a/router_dyndns/assets/social-preview.png and b/router_dyndns/assets/social-preview.png differ diff --git a/router_dyndns/assets/social-preview.webp b/router_dyndns/assets/social-preview.webp index de11e2e..8318972 100644 Binary files a/router_dyndns/assets/social-preview.webp and b/router_dyndns/assets/social-preview.webp differ diff --git a/router_dyndns/config.py b/router_dyndns/config.py index 487c28e..98ff8d5 100644 --- a/router_dyndns/config.py +++ b/router_dyndns/config.py @@ -9,14 +9,11 @@ from .ddns_models import DdnsSettings def settings_from_env() -> DdnsSettings: return DdnsSettings( database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")), - shared_secret=os.getenv("DDNS_SHARED_SECRET", ""), admin_password=os.getenv("DDNS_ADMIN_PASSWORD", ""), 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"), trusted_hosts=_csv_set("DDNS_TRUSTED_HOSTS"), 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), 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), @@ -30,7 +27,6 @@ def settings_from_env() -> DdnsSettings: require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"), cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""), cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""), - cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72), cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080), cleanup_event_history_hours=_env_int("DDNS_CLEANUP_EVENT_HISTORY_HOURS", 24 * 180), cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600), diff --git a/router_dyndns/ddns_api.py b/router_dyndns/ddns_api.py index 5908393..d499173 100644 --- a/router_dyndns/ddns_api.py +++ b/router_dyndns/ddns_api.py @@ -1,27 +1,17 @@ from __future__ import annotations from .schemas import ( - CustomHostnameRequest, - DomainChallengeRequest, - DomainChallengeResponse, ErrorResponse, HostnameCredentials, MagicHostnameRequest, ManagedRecord, RouterUpdateResponse, - VerifyDomainRequest, - VerifyDomainResponse, ) __all__ = [ - "CustomHostnameRequest", - "DomainChallengeRequest", - "DomainChallengeResponse", "ErrorResponse", "HostnameCredentials", "MagicHostnameRequest", "ManagedRecord", "RouterUpdateResponse", - "VerifyDomainRequest", - "VerifyDomainResponse", ] diff --git a/router_dyndns/ddns_models.py b/router_dyndns/ddns_models.py index 9511da9..726f916 100644 --- a/router_dyndns/ddns_models.py +++ b/router_dyndns/ddns_models.py @@ -12,14 +12,11 @@ class DdnsSettings(BaseModel): model_config = ConfigDict(extra="forbid") database_path: Path = Field(default=Path("ddns.sqlite3")) - shared_secret: str = Field(default="") admin_password: str = Field(default="") admin_path: str = Field(default="/admin") - allowed_hosts: set[str] = Field(default_factory=set) public_base_url: str = Field(default="http://localhost:8080") trusted_hosts: set[str] = Field(default_factory=set) hostname_suffix: str = Field(default="") - verification_prefix: str = Field(default="_karldns") 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) 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) cloudflare_api_token: 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_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) @@ -69,9 +65,7 @@ class DdnsSettings(BaseModel): "/admin-tables.js", "/api", "/app.js", - "/claim", "/clipboard.js", - "/d", "/docs", "/favicon.ico", "/healthz", diff --git a/router_dyndns/ddns_security.py b/router_dyndns/ddns_security.py index b295bf1..be2e671 100644 --- a/router_dyndns/ddns_security.py +++ b/router_dyndns/ddns_security.py @@ -156,17 +156,15 @@ def is_rate_limited_path(path: str) -> bool: limited_paths = { "/magic", "/accounts", - "/request-domain", - "/verify-domain", "/nic/update", } - return path in limited_paths or path.startswith(("/u/", "/m/", "/d/", "/api/v1/updates/")) or path.startswith( - ("/api/v1/hostnames/", "/api/v1/domains/", "/api/v1/management/") + return path in limited_paths or path.startswith(("/u/", "/m/", "/api/v1/updates/")) or path.startswith( + ("/api/v1/hostnames/", "/api/v1/management/") ) def rate_limit_bucket(path: str) -> str: - 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): return prefix.rstrip("/") return path diff --git a/router_dyndns/ddns_service.py b/router_dyndns/ddns_service.py index d83ab5d..c9895bc 100644 --- a/router_dyndns/ddns_service.py +++ b/router_dyndns/ddns_service.py @@ -12,7 +12,7 @@ from dataclasses import dataclass 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_security import client_ip from .ddns_store import DdnsStore @@ -49,56 +49,6 @@ class DdnsService: continue 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: try: delete_dns_records(hostname, self.settings) @@ -213,49 +163,18 @@ class DdnsService: slug = urllib.parse.quote(account["management_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: suffix = self.settings.hostname_suffix.strip().lower().strip(".") if not suffix: raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames") return f"{secrets.token_hex(6)}.{suffix}" - def best_verified_parent( - self, - hostname: str, - claim_secret: str | None = None, - owner_hostname: str | None = None, - ) -> str | None: - 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 hostname_is_managed(self, hostname: str) -> bool: + suffix = self.settings.hostname_suffix.strip().lower().strip(".") + if not suffix: + return True + normalized = hostname.strip().lower().rstrip(".") + return normalized == suffix or normalized.endswith(f".{suffix}") def _lock_for(self, hostname: str) -> threading.Lock: return self._locks[hash(hostname) % self._LOCK_STRIPES] @@ -320,28 +239,6 @@ def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = 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: label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?" return bool(re.fullmatch(rf"{label}(\.{label})+", value)) - - -def dns_txt_contains(name: str, expected: str) -> bool: - 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 diff --git a/router_dyndns/event_statuses.py b/router_dyndns/event_statuses.py index e880e30..526bdb4 100644 --- a/router_dyndns/event_statuses.py +++ b/router_dyndns/event_statuses.py @@ -12,10 +12,6 @@ DASHBOARD_PASSWORD_REMOVED = "dashboard_password_removed" DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated" DASHBOARD_ROUTER_CREDENTIALS_ROTATED = "dashboard_router_credentials_rotated" DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted" -DASHBOARD_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_UPDATE_URL_ROTATED = "admin_update_url_rotated" diff --git a/router_dyndns/routers/admin.py b/router_dyndns/routers/admin.py index 9e8a7c5..2cc6674 100644 --- a/router_dyndns/routers/admin.py +++ b/router_dyndns/routers/admin.py @@ -39,14 +39,12 @@ def make_router(admin_path: str = "/admin") -> APIRouter: service, settings, 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_scheduled_tasks(), store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)), store.get_service_metrics(), { "accounts": store.count_accounts(), - "domain_challenges": store.count_domain_challenges(), "cleanup_runs": store.count_cleanup_runs(), "update_events": store.count_update_events(), }, @@ -62,7 +60,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter: service: ServiceDep, _: None = Depends(authenticate_admin), accounts_offset: int = Query(0, ge=0), - domain_claims_offset: int = Query(0, ge=0), cleanup_runs_offset: int = Query(0, ge=0), update_events_offset: int = Query(0, ge=0), ) -> str: @@ -72,7 +69,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter: store, { "accounts": accounts_offset, - "domain_challenges": domain_claims_offset, "cleanup_runs": cleanup_runs_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")) result = await run_in_threadpool( store.cleanup, - settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours, settings.cleanup_event_history_hours, ) notice = _admin_secret_panel( "Cleanup done", [ - _copy_row("Domain claims removed", str(result["domain_challenges"])), _copy_row("Unused hostnames removed", str(result["unused_accounts"])), _copy_row("Old update events removed", str(result["update_events"])), ], diff --git a/router_dyndns/routers/api.py b/router_dyndns/routers/api.py index 6eb851e..21f528b 100644 --- a/router_dyndns/routers/api.py +++ b/router_dyndns/routers/api.py @@ -1,24 +1,17 @@ from __future__ import annotations -import sqlite3 - from fastapi import APIRouter, HTTPException, Request, Response, status from starlette.concurrency import run_in_threadpool from ..ddns_dns import DnsProviderError -from ..ddns_service import BadIpError, normalize_domain +from ..ddns_service import BadIpError from ..dependencies import ServiceDep, StoreDep from ..schemas import ( - CustomHostnameRequest, - DomainChallengeRequest, - DomainChallengeResponse, ErrorResponse, HostnameCredentials, MagicHostnameRequest, ManagedRecord, RouterUpdateResponse, - VerifyDomainRequest, - VerifyDomainResponse, _credentials_response, _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) -@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( "/updates/{update_slug}", response_model=RouterUpdateResponse, diff --git a/router_dyndns/routers/assets.py b/router_dyndns/routers/assets.py index baf5091..53a78ef 100644 --- a/router_dyndns/routers/assets.py +++ b/router_dyndns/routers/assets.py @@ -60,6 +60,10 @@ THEME_JS = """ CLIPBOARD_JS = """ 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]"); if (toggle) { const group = toggle.closest(".copy-field"); @@ -84,13 +88,29 @@ document.addEventListener("click", async (event) => { if (!button) return; try { await navigator.clipboard.writeText(button.dataset.copy || ""); - const old = button.textContent; - button.textContent = "Copied"; + const label = button.querySelector("[data-copy-label]"); + 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(() => { + menu.removeAttribute("open"); + }, 250); + } setTimeout(() => { - button.textContent = old; + button.classList.remove("is-copied"); + if (label) label.textContent = oldLabel; }, 1200); } catch (_) {} }); + +document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + document.querySelectorAll("details.copy-menu[open]").forEach((menu) => { + menu.removeAttribute("open"); + }); +}); """.strip() diff --git a/router_dyndns/routers/management.py b/router_dyndns/routers/management.py index ef239a6..5c908bc 100644 --- a/router_dyndns/routers/management.py +++ b/router_dyndns/routers/management.py @@ -18,7 +18,7 @@ from ..web.forms import first_form_value, request_form from ..web.html_utils import trusted from ..web.templates import _page from ..web.templates_management import _render_management_page, _render_management_unlock_page -from . import management_domains, management_passwords +from . import management_passwords from .management_shared import ( get_active_management_account, get_active_management_account_async, @@ -30,7 +30,6 @@ from .path_params import ManagementSlugPath router = APIRouter() router.include_router(management_passwords.router) -router.include_router(management_domains.router) @router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False) diff --git a/router_dyndns/routers/management_domains.py b/router_dyndns/routers/management_domains.py deleted file mode 100644 index 2020b43..0000000 --- a/router_dyndns/routers/management_domains.py +++ /dev/null @@ -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, - ) diff --git a/router_dyndns/routers/management_shared.py b/router_dyndns/routers/management_shared.py index b6f0e0d..30d6456 100644 --- a/router_dyndns/routers/management_shared.py +++ b/router_dyndns/routers/management_shared.py @@ -78,7 +78,6 @@ async def render_management_page_async( account: ManagementAccount, csrf_token: str, message: str | None = None, - challenge: dict[str, str | None] | None = None, created_account: dict[str, str] | None = None, ) -> str: return await run_in_threadpool( @@ -89,7 +88,6 @@ async def render_management_page_async( account, csrf_token, message, - challenge, created_account, ) diff --git a/router_dyndns/routers/path_params.py b/router_dyndns/routers/path_params.py index 0b22131..f4d7c83 100644 --- a/router_dyndns/routers/path_params.py +++ b/router_dyndns/routers/path_params.py @@ -26,12 +26,3 @@ ManagementSlugPath = Annotated[ 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.", - ), -] diff --git a/router_dyndns/routers/public.py b/router_dyndns/routers/public.py index 2f72c11..2822a9f 100644 --- a/router_dyndns/routers/public.py +++ b/router_dyndns/routers/public.py @@ -1,13 +1,10 @@ from __future__ import annotations -import sqlite3 - from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response from starlette.concurrency import run_in_threadpool from ..ddns_security import client_ip -from ..ddns_service import normalize_domain from ..dependencies import ServiceDep, SettingsDep from ..web.forms import first_form_value, request_form from ..web.templates_public import _render_privacy_page, _render_public_home @@ -32,45 +29,15 @@ async def magic_account(request: Request, settings: SettingsDep, service: Servic 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) async def self_service_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str: form = await request_form(request, settings) mode = first_form_value(form, "mode") - if mode == "managed": - 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, - ) + if mode and mode != "managed": + raise HTTPException(status_code=400, detail="custom hostnames are not supported; create a KarlDNS hostname and use a CNAME") + if first_form_value(form, "hostname") or first_form_value(form, "claim_secret"): + raise HTTPException(status_code=400, detail="custom hostnames are not supported; create a KarlDNS hostname and use a CNAME") + account = await run_in_threadpool(service.create_managed_account, None) return _render_public_home(service, settings, account, None) @@ -98,7 +65,6 @@ def robots_txt(settings: SettingsDep) -> str: "User-agent: *", f"Disallow: {admin_path}/", "Disallow: /m/", - "Disallow: /d/", "Disallow: /u/", "Disallow: /nic/", "Disallow: /api/", diff --git a/router_dyndns/routers/updates.py b/router_dyndns/routers/updates.py index d89e659..e18bf0d 100644 --- a/router_dyndns/routers/updates.py +++ b/router_dyndns/routers/updates.py @@ -1,11 +1,9 @@ from __future__ import annotations -import secrets - from fastapi import APIRouter, Depends, HTTPException, Query, Request 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 router = APIRouter() @@ -25,13 +23,14 @@ def slug_update( account = store.get_account_by_slug(update_slug) if not account or account["disabled"]: raise HTTPException(status_code=404, detail="not found") + if not service.hostname_is_managed(str(account["hostname"])): + raise HTTPException(status_code=404, detail="not found") return apply_update_response(service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr) @router.get("/nic/update", response_class=PlainTextResponse, response_model=None) def update( request: Request, - settings: SettingsDep, store: StoreDep, service: ServiceDep, credentials: tuple[str | None, str | None] = Depends(authenticate_update), @@ -45,17 +44,10 @@ def update( host = (hostname or domain or "").strip().lower().rstrip(".") if not host: 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) supplied_user, supplied_token = credentials account_ok = store.verify_account(host, supplied_user, supplied_token) - global_secret_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: + if not account_ok: return PlainTextResponse("badauth", status_code=401) return apply_update_response(service, request, host, myip, ipaddr, myipv6, ip6addr) diff --git a/router_dyndns/schemas.py b/router_dyndns/schemas.py index 53e1ec6..e8738d7 100644 --- a/router_dyndns/schemas.py +++ b/router_dyndns/schemas.py @@ -33,41 +33,6 @@ class ManagedRecord(BaseModel): 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): status: str = Field(examples=["good"]) hostname: str diff --git a/router_dyndns/store/__init__.py b/router_dyndns/store/__init__.py index a616897..908a082 100644 --- a/router_dyndns/store/__init__.py +++ b/router_dyndns/store/__init__.py @@ -7,7 +7,6 @@ from sqlite3 import Connection from time import monotonic from .accounts import AccountsMixin -from .domains import DomainsMixin from .metrics import MetricsMixin from .rate_limit import RateLimitCleanupMixin from .records import RecordsEventsMixin @@ -15,7 +14,7 @@ from .scheduler import SchedulerMixin 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: self.database_path = database_path self._connections = threading.local() diff --git a/router_dyndns/store/domains.py b/router_dyndns/store/domains.py deleted file mode 100644 index d44673b..0000000 --- a/router_dyndns/store/domains.py +++ /dev/null @@ -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]) diff --git a/router_dyndns/store/rate_limit.py b/router_dyndns/store/rate_limit.py index d7038e7..72aa78c 100644 --- a/router_dyndns/store/rate_limit.py +++ b/router_dyndns/store/rate_limit.py @@ -13,7 +13,7 @@ class RateLimitCleanupMixin: with self.connect() as conn: rows = conn.execute( """ - SELECT created_at, domain_challenges_deleted, unused_accounts_deleted + SELECT created_at, unused_accounts_deleted FROM cleanup_runs ORDER BY created_at DESC LIMIT ? OFFSET ? @@ -116,19 +116,13 @@ class RateLimitCleanupMixin: def cleanup( self, - challenge_hours: int, unused_account_hours: int, event_history_hours: int = 24 * 180, ) -> dict[str, int]: 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() event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat() 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( """ DELETE FROM accounts @@ -151,13 +145,12 @@ class RateLimitCleanupMixin: conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 60,)) conn.execute( """ - INSERT INTO cleanup_runs(created_at, domain_challenges_deleted, unused_accounts_deleted) - VALUES (?, ?, ?) + INSERT INTO cleanup_runs(created_at, unused_accounts_deleted) + VALUES (?, ?) """, - (datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount), + (datetime.now(UTC).isoformat(), account_cursor.rowcount), ) return { - "domain_challenges": challenge_cursor.rowcount, "unused_accounts": account_cursor.rowcount, "event_rollups": rollup_cursor.rowcount, "update_events": event_cursor.rowcount, diff --git a/router_dyndns/store/schema.py b/router_dyndns/store/schema.py index 3a36ade..973a552 100644 --- a/router_dyndns/store/schema.py +++ b/router_dyndns/store/schema.py @@ -90,11 +90,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None: CREATE TABLE IF NOT EXISTS cleanup_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, - domain_challenges_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( """ @@ -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 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 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: conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") 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})")} if column not in columns: 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") diff --git a/router_dyndns/store/types.py b/router_dyndns/store/types.py index 49d8ed8..4ab0a07 100644 --- a/router_dyndns/store/types.py +++ b/router_dyndns/store/types.py @@ -18,15 +18,6 @@ class AccountRow(TypedDict, total=False): 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): hostname: str ipv4: str | None @@ -40,7 +31,6 @@ class UpdateEventRow(TypedDict, total=False): class CleanupRunRow(TypedDict): created_at: str - domain_challenges_deleted: int unused_accounts_deleted: int diff --git a/router_dyndns/web/security_headers.py b/router_dyndns/web/security_headers.py index 43b29ab..c292493 100644 --- a/router_dyndns/web/security_headers.py +++ b/router_dyndns/web/security_headers.py @@ -107,11 +107,11 @@ def is_admin_path(path: str, settings: DdnsSettings) -> bool: def is_secret_response(request: Request) -> bool: path = request.url.path settings = request.app.state.settings - if is_admin_path(path, settings) or path.startswith(("/m/", "/d/", "/u/")): + if is_admin_path(path, settings) or path.startswith(("/m/", "/u/")): 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 == "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: @@ -120,7 +120,7 @@ def is_noindex_response(request: Request) -> bool: return True if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}: return True - if path.startswith(("/api/", "/m/", "/d/", "/u/", "/nic/")): + if path.startswith(("/api/", "/m/", "/u/", "/nic/")): return True return is_admin_path(path, request.app.state.settings) @@ -154,7 +154,6 @@ def redacted_access_path(path: str) -> str: secret_prefixes = { "/u": "/u/", "/m": "/m/", - "/d": "/d/", "/api/v1/updates": "/api/v1/updates/", "/api/v1/management": "/api/v1/management/", } diff --git a/router_dyndns/web/styles.py b/router_dyndns/web/styles.py index 4103f32..87815d2 100644 --- a/router_dyndns/web/styles.py +++ b/router_dyndns/web/styles.py @@ -544,17 +544,36 @@ select:focus-visible { background: var(--rp-field); } -.manual-code { - overflow-x: auto; - padding: 16px; - border: 1px solid var(--rp-line); - border-radius: 12px; - color: var(--rp-terminal-ink); - background: var(--rp-terminal); - font: 400 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +.copy-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.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 { + position: relative; display: grid; gap: 8px; padding: 18px 20px; @@ -565,6 +584,14 @@ select:focus-visible { 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 { display: block; overflow-wrap: anywhere; @@ -572,13 +599,151 @@ select:focus-visible { 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 { - padding: 32px 0; + padding: 28px 0; background: var(--rp-canvas); } -.site-footer .nav-link:hover { - color: var(--rp-ink) !important; +.footer-inner { + 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) { @@ -679,6 +844,30 @@ select:focus-visible { 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 { font-size: 28px; line-height: 1.15; diff --git a/router_dyndns/web/templates.py b/router_dyndns/web/templates.py index 4433f96..9654037 100644 --- a/router_dyndns/web/templates.py +++ b/router_dyndns/web/templates.py @@ -5,6 +5,26 @@ import json from .html_utils import SafeHtml, attr, escape, join_html, trusted +def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml: + if name == "check": + path = '' + elif name == "more": + path = ( + '' + ) + else: + path = ( + '' + ) + return trusted( + f'' + ) + + def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml: account_html = "" if label: @@ -63,16 +83,66 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtm {safe_label}{badge} {value_field} {reveal_button} - + """ ) -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_value = attr(value) - return trusted(f'') + safe_style = attr(style) + return trusted( + f'' + ) + + +def _copy_icon_button(label: str, value: str) -> SafeHtml: + safe_label = escape(label) + safe_value = attr(value) + return trusted( + f'' + ) + + +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'' + ) + return trusted( + f""" +
+ + {_svg_icon("more", "more-icon")} + + +
+ """ + ) + + +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"{escape(line)}" for line in commands.splitlines()) + return trusted( + f""" +
+ {_copy_icon_button(copy_label, commands)} + {code_lines} +
+ """ + ) 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: - safe_commands = escape(commands) return trusted( f"""
@@ -95,8 +164,7 @@ def _manual_update_panel(commands: str) -> SafeHtml:

Manual updates

FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.

-
{safe_commands}
- {_copy_button("Copy curl", commands)} + {_code_window(commands, copy_label="manual update commands", class_name="manual-code")} """ ) @@ -125,8 +193,8 @@ def _secret_notice() -> SafeHtml: return trusted( """
- Public: Domain name and CNAME target can be shared. - Secret: Update URLs, router password, dashboard link, and claim links can update or manage this hostname. + Public: Hostname and CNAME target can be shared. + Secret: Update URLs, router password, and dashboard link can update or manage this hostname. Secrets are hidden on screen. Copy still copies the real value.
""" @@ -159,15 +227,33 @@ def _site_footer() -> SafeHtml: return trusted( """ @@ -242,10 +328,10 @@ def _page( {preload_logo_html} - + {extra_scripts_html} - + {extra_styles_html} {structured_data_html} @@ -257,7 +343,10 @@ def _page( __all__ = [ "SafeHtml", "_copy_button", + "_copy_icon_button", + "_copy_menu", "_copy_row", + "_code_window", "_fritzbox_fields", "_manual_update_commands", "_manual_update_panel", diff --git a/router_dyndns/web/templates_admin.py b/router_dyndns/web/templates_admin.py index 5fe343d..a3105f9 100644 --- a/router_dyndns/web/templates_admin.py +++ b/router_dyndns/web/templates_admin.py @@ -11,7 +11,6 @@ from .templates_admin_tables import ( _account_row, _admin_table_pagination, _admin_table_toolbar, - _domain_claim_row, _event_row, ) @@ -34,7 +33,6 @@ def _render_admin_page( service: DdnsService, settings: DdnsSettings, accounts: list[dict[str, str | int | None]], - domain_challenges: list[dict[str, str | None]], cleanup_runs: list[dict[str, str | int]], scheduled_tasks: 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 """ No hostnames yet. """ - domain_rows = "\n".join(_domain_claim_row(challenge) for challenge in domain_challenges) or """ - No domain claims yet. - """ event_rows = "\n".join(_event_row(event) for event in update_events) or """ No update events yet. """ 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))) 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)) - 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)) cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf) return _page( @@ -81,7 +68,7 @@ def _render_admin_page(

KarlDNS

Operator console

-

Review hostnames, domain checks, updates, cleanup, and secret changes.

+

Review hostnames, updates, cleanup, and secret changes.

@@ -115,7 +102,7 @@ def _render_admin_page(
- + {rows}
DomainUserIPv4IPv6UpdatedLinksActions
HostnameUserIPv4IPv6UpdatedLinksActions
@@ -125,27 +112,6 @@ def _render_admin_page(
-
-
-
-
-

Domain claims

-

Domain verification

-

Showing {escape(len(domain_challenges))} of {domain_count}.

-
-
- {_admin_table_toolbar("domain-claims", "Search domain claims")} -
- - - {domain_rows} -
DomainOwner dashboardStatusCreatedVerified
-
- {_admin_table_pagination()} - {domain_pager} -
-
-
@@ -158,7 +124,7 @@ def _render_admin_page( {_admin_table_toolbar("update-events", "Search update events")}
- + {event_rows}
TimeDomainStatusIPv4IPv6CountDetail
TimeHostnameStatusIPv4IPv6CountDetail
@@ -207,7 +173,6 @@ def _cleanup_panel( last_error = task.get("last_error") or "" interval = task.get("interval_seconds") or settings.cleanup_interval_seconds last_cleanup = cleanup_runs[0] if cleanup_runs else {} - domains_removed = last_cleanup.get("domain_challenges_deleted", 0) accounts_removed = last_cleanup.get("unused_accounts_deleted", 0) error_html = ( trusted(f'

Last error: {escape(last_error)}

') @@ -237,7 +202,7 @@ def _cleanup_panel(

Cleanup

-

Last cleanup removed {escape(domains_removed)} domain claims and {escape(accounts_removed)} unused hostnames.

+

Last cleanup removed {escape(accounts_removed)} unused hostnames.

{error_html}
diff --git a/router_dyndns/web/templates_admin_tables.py b/router_dyndns/web/templates_admin_tables.py index 5cea5bb..eceabed 100644 --- a/router_dyndns/web/templates_admin_tables.py +++ b/router_dyndns/web/templates_admin_tables.py @@ -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"{domain}{owner}{status}{created}{verified}" - - -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"{created}{domains}{accounts}" - - -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"{name}{status}{next_run}{lease_expires}{last_finished}{last_error}" - - def _event_row(event: dict[str, str | int | None]) -> str: created = escape(event.get("created_at") or "-") hostname = escape(event.get("hostname") or "-") diff --git a/router_dyndns/web/templates_management.py b/router_dyndns/web/templates_management.py index beb38a2..ac84352 100644 --- a/router_dyndns/web/templates_management.py +++ b/router_dyndns/web/templates_management.py @@ -4,7 +4,7 @@ from ..ddns_models import DdnsSettings from ..ddns_service import DdnsService from .html_utils import SafeHtml, attr, escape, trusted from .templates import ( - _copy_button, + _copy_menu, _copy_row, _fritzbox_fields, _manual_update_commands, @@ -24,7 +24,6 @@ def _render_management_page( account: dict[str, str | int | None], csrf_token: str, message: str | None = None, - challenge: dict[str, str | None] | None = None, created_account: dict[str, str] | None = None, ) -> str: account_for_url = { @@ -48,7 +47,6 @@ def _render_management_page( """ message_html = _management_message(message) 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"))) return _page( "KarlDNS management", @@ -71,12 +69,11 @@ def _render_management_page(

Router settings

Router update settings

-

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.

+

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.

{_one_time_secret_notice()} {_secret_notice()} -
- {_copy_button("Copy router fields", fritzbox_fields)} - {_copy_button("Copy CNAME", cname_record)} +
+ {_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])}
@@ -109,7 +106,6 @@ def _render_management_page(
- {domain_claim} {dashboard_password_panel}
@@ -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""" -
-
-

Custom domain

-

Use your domain

-

Create a TXT record, verify it here, then create a hostname under that domain.

-
- - - - - -
- """ - ) - if not challenge: - return trusted( - f""" -
-
{create_claim}
-
- """ - ) - - 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""" -
-
- {create_claim} -
-
-

Domain claim

-

{escape(domain)}

-

Status: {escape(status)}

-

This claim belongs to this dashboard. Other dashboards cannot use it.

-
-
TXT name and value are public. Claim secret and private claim link are secret.
-
- {_copy_button("Copy TXT", txt_bundle)} -
-
- {_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")} -
-
- - - - -
- {create_hostname} -
-
-
- """ - ) - - -def _management_custom_hostname_form(management_slug: str, claim_secret: str, csrf_token: str | None = None) -> SafeHtml: - csrf_field = f'' if csrf_token else "" - return trusted( - f""" -
- {csrf_field} - - - -
- """ - ) - - def _user_event_row(event: dict[str, str | int | None]) -> str: created = escape(event.get("created_at") or "-") status = escape(event.get("status") or "-") diff --git a/router_dyndns/web/templates_public.py b/router_dyndns/web/templates_public.py index 112b9aa..0672568 100644 --- a/router_dyndns/web/templates_public.py +++ b/router_dyndns/web/templates_public.py @@ -3,8 +3,8 @@ from __future__ import annotations from ..ddns_models import DdnsSettings from ..ddns_service import DdnsService from .html_utils import escape, trusted -from .templates import _message_band, _page, _top_nav -from .templates_public_sections import _credentials_panel, _custom_domain_flow, _linux_installer_section +from .templates import _code_window, _message_band, _page, _top_nav +from .templates_public_sections import _credentials_panel, _linux_installer_section def _render_public_home( @@ -12,15 +12,24 @@ def _render_public_home( settings: DdnsSettings, created_account: dict[str, str] | None, message: str | None, - challenge: dict[str, str | None] | None = None, ) -> str: 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/?myip=") + cname_example = f"home.example.com. CNAME your-name.{hostname_suffix}." + create_demo = "$ curl -sS 'https://karldns.de/u/?myip=203.0.113.10'\ngood 203.0.113.10" + manual_demo = "\n".join( + [ + "# explicit IPv4", + "curl -sS 'https://karldns.de/u/?myip=203.0.113.10'", + "# source IP fallback", + "curl -sS 'https://karldns.de/u/'", + ] + ) created_html = _credentials_panel(service, created_account) - message_html = _message_band(message) if message and not challenge else "" - challenge_html = _custom_domain_flow(service, challenge, message) - description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, custom domains, Linux cron jobs, and home networks." + message_html = _message_band(message) if message else "" + description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks." return _page( "KarlDNS", trusted(f""" @@ -33,7 +42,7 @@ def _render_public_home(
{demo_update_url}
-

KarlDNS gives you a private DynDNS update URL under {suffix}. Add it to your router, or use it from a script.

+

KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.

Create hostname Manual updates @@ -47,12 +56,12 @@ def _render_public_home(

Hostname

No account needed.

-

Use a KarlDNS hostname now, or verify your own domain when you need it.

+

Create a KarlDNS hostname. Use it directly, or point your own DNS name at it with a CNAME.

-

KarlDNS domain

+

KarlDNS hostname

Create a hostname

You get a hostname, a private update URL, and router fields if your router needs them.

@@ -62,38 +71,25 @@ def _render_public_home(
-

Use your own domain

-

Verify your domain

-

Add one TXT record first. KarlDNS only updates domains it can verify.

- - - - +

Optional DNS alias

+

Add a CNAME

+

After creation, add a CNAME at your DNS provider:

+ {_code_window(cname_example, copy_label="CNAME example")} +

KarlDNS updates only the generated hostname. The external DNS name follows it through CNAME.

- + {_code_window(create_demo, copy_label="update example", class_name="mt-4")}

Update from a router or script.

Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.

-
- # explicit IPv4 - curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10' - # source IP fallback - curl -sS 'https://karldns.de/u/<secret>' -
+ {_code_window(manual_demo, copy_label="manual update examples", class_name="text-start mt-4")}
{_linux_installer_section()} - {challenge_html} """), description=description, @@ -114,7 +110,7 @@ def _render_public_home( "featureList": [ "Dynamic DNS for FRITZ!Box routers", "Accountless private update URLs", - "Custom domain TXT verification", + "CNAME target for external DNS names", "Linux cron updater", ], }, @@ -122,7 +118,7 @@ def _render_public_home( def _render_privacy_page(settings: DdnsSettings) -> str: base_url = settings.public_base_url.rstrip("/") - description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, DNS verification records, IP history, and cleanup behavior." + description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior." return _page( "Legal and Privacy", trusted(f""" @@ -139,15 +135,15 @@ def _render_privacy_page(settings: DdnsSettings) -> str:

Data processed

-

KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, TXT verification records, recent IP history, timestamps, and service events.

+

KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, recent IP history, timestamps, and service events.

Secrets

-

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.

+

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.

Logs and retention

-

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.

+

KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.

Contact

diff --git a/router_dyndns/web/templates_public_sections.py b/router_dyndns/web/templates_public_sections.py index 0c992ad..2d16d8e 100644 --- a/router_dyndns/web/templates_public_sections.py +++ b/router_dyndns/web/templates_public_sections.py @@ -1,9 +1,10 @@ from __future__ import annotations from ..ddns_service import DdnsService -from .html_utils import SafeHtml, attr, escape, trusted +from .html_utils import SafeHtml, escape, trusted from .templates import ( - _copy_button, + _code_window, + _copy_menu, _copy_row, _fritzbox_fields, _manual_update_commands, @@ -18,7 +19,6 @@ def _linux_installer_section() -> SafeHtml: system_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" user_install = "curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user" clean_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sh -s -- --mode user --delete-config" - all_commands = "\n".join([system_install, system_uninstall, user_install, clean_uninstall]) return trusted( f"""
@@ -29,15 +29,16 @@ def _linux_installer_section() -> SafeHtml:

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.

-
- {escape(system_install)} - {escape(system_uninstall)} +
+

Install

+

System install for most Linux servers.

-
- {_copy_button("Copy install", system_install)} - {_copy_button("Copy uninstall", system_uninstall)} - {_copy_button("Copy all commands", all_commands)} + {_code_window(system_install, copy_label="install command")} +
+

Uninstall

+

Stops the updater and leaves your private URLs on disk.

+ {_code_window(system_uninstall, copy_label="uninstall command")}
Uninstall keeps your private URLs by default. Use --delete-config only when you want to remove everything.
@@ -45,67 +46,8 @@ def _linux_installer_section() -> SafeHtml:

User install

Use this when you do not want a system-wide install.

-
- {escape(user_install)} - {escape(clean_uninstall)} -
-
- {_copy_button("Copy user install", user_install)} - {_copy_button("Copy clean user uninstall", clean_uninstall)} -
-
-
- - """ - ) - - -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""" -
-
- 1 -

Add the TXT record

-

{status}

-
TXT name and value are public. The private claim link is secret.
- {_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")} -
- - - -
-
-
- """ - ) - create_form = _custom_hostname_form(claim_secret) if challenge.get("verified_at") else _locked_custom_hostname_card() - - return trusted( - f""" -
-
-
-

Custom domain

-

Verify your domain

-

Use this when KarlDNS should update records inside your domain. For a simpler setup, create a KarlDNS hostname and point a CNAME to it.

-
-
- {challenge_html} - {create_form} + {_code_window(user_install, copy_label="user install command")} + {_copy_menu("More copy options", [("Clean user uninstall", clean_uninstall)])}
@@ -134,10 +76,8 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N

Use the Update-URL directly. Username and password are only for routers that require separate fields.

{_one_time_secret_notice()} {_secret_notice()} -
- {_copy_button("Copy router fields", fritzbox_fields)} - {_copy_button("Copy full URL", credentialed_update_url)} - {_copy_button("Copy CNAME", cname_record)} +
+ {_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Full update URL", credentialed_update_url), ("CNAME", cname_record)])}
{_copy_row("Update-URL:", update_url, "Secret")} @@ -153,39 +93,3 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N """ ) - - -def _custom_hostname_form(claim_secret: str) -> SafeHtml: - return trusted( - f""" -
-
- 2 -

Create hostname

-
- - - -

Router fields are created after this step.

- -
-
-
- """ - ) - - -def _locked_custom_hostname_card() -> SafeHtml: - return trusted( - """ -
-
- 2 -

Create hostname

-

Available after KarlDNS can see the TXT record.

-
-
- """ - ) diff --git a/tests/test_admin.py b/tests/test_admin.py index 51a6734..97b3085 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -6,20 +6,8 @@ from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app 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: 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")) 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 "Generate credentials" 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-table-search") == 3 - assert response.text.count("data-table-page-size") == 3 - assert response.text.count("data-table-pagination") == 3 + assert response.text.count("data-admin-table") == 2 + assert response.text.count("data-table-search") == 2 + assert response.text.count("data-table-page-size") == 2 + assert response.text.count("data-table-pagination") == 2 assert "admin-table-scroll" in response.text assert "Cleanup" 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 "Blocked requests" in response.text assert "Hostnames" in response.text - assert "Domain claims" 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: diff --git a/tests/test_api.py b/tests/test_api.py index 342a5cf..ed46f4c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -63,11 +63,8 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None: assert "/events" not in schema["paths"] assert "/" 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 "/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 all(tag["name"] != "admin" for tag in schema["tags"]) 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() expected_public_paths = { - "/api/v1/domains/challenges", - "/api/v1/domains/verify", - "/api/v1/hostnames/custom", "/api/v1/hostnames/magic", "/api/v1/management/{management_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"), [ ("/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: app = make_app( 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" -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: app = make_app( DdnsSettings( @@ -309,47 +251,3 @@ def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None: assert response.status_code == 400 assert response.json()["detail"] == "myipv6 must be an IPv6 address" 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" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index acc184b..cf4c950 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,5 +1,6 @@ import asyncio import logging +import sqlite3 import time from contextlib import contextmanager 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), ) - store.cleanup(challenge_hours=1, unused_account_hours=1) + store.cleanup(unused_account_hours=1) with store.connect() as conn: 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 { "accounts", "cleanup_runs", - "domain_challenges", "rate_limits", "records", "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" +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: 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 { "idx_accounts_created_at", - "idx_domain_challenges_claim_hash", - "idx_domain_challenges_unverified_created", "idx_rate_limits_window", "idx_update_events_hostname_created_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"), ) - 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 events = store.list_update_events() 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" app = make_app( DdnsSettings( database_path=database_path, admin_password="admin", - cleanup_challenge_hours=1, cleanup_unused_account_hours=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) 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") active_account = store.create_account("active.ddns.example.net", "router") store.upsert("active.ddns.example.net", "203.0.113.10", None) old = "2000-01-01T00:00:00+00:00" 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, active_account["hostname"])) 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(active_account["update_slug"]) is not None 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.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") caplog.set_level(logging.ERROR, logger="router_dyndns.app") diff --git a/tests/test_security.py b/tests/test_security.py index 110309d..b3d3ea5 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -51,22 +51,6 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None: 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: app = make_app( DdnsSettings( @@ -121,7 +105,6 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path "/api/v1/updates/short", "/m/short", "/api/v1/management/short", - "/d/short", ], ) def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None: diff --git a/tests/test_ui.py b/tests/test_ui.py index edc69c3..1fd0c92 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -3,7 +3,6 @@ from pathlib import Path from fastapi.testclient import TestClient -import router_dyndns.ddns_service as ddns_service 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 "Benutzername (optional):" in response.text assert "Kennwort (optional):" in response.text - assert "Copy router fields" in response.text - assert "Copy CNAME" in response.text + assert "Router fields" in response.text + assert "CNAME" in response.text assert "Manual updates" in response.text assert "curl -sS" 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 +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: app = make_app( DdnsSettings( @@ -66,10 +82,10 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_ assert "Benutzername (optional):" in response.text assert "Kennwort (optional):" in response.text assert "Private dashboard:" in response.text - assert "Copy router fields" in response.text - assert "Copy CNAME" in response.text + assert "Router fields" in response.text + assert "CNAME" 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("Public") >= 3 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 "Benutzername (optional):" in management.text assert "Kennwort (optional):" in management.text - assert "Copy router fields" in management.text - assert "Copy CNAME" in management.text + assert "Router fields" in management.text + assert "CNAME" in management.text assert "Manual updates" in management.text assert "curl -sS" 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"}) 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 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_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: @@ -418,157 +431,6 @@ def test_management_page_shows_ip_history(tmp_path: Path) -> None: 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: app = make_app( 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 'name="username"' not in response.text assert "You get a hostname, a private update URL, and router fields if your router needs them." in response.text + assert "Add a CNAME" in response.text assert "Linux cron updater" in response.text assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text @@ -619,10 +482,10 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None: assert response.status_code == 200 assert '' in response.text - assert '' in response.text + assert '' in response.text assert '' not in response.text assert '' in response.text - assert '' in response.text + assert '' in response.text assert '' not in response.text assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text assert "