diff --git a/.env.example b/.env.example index 63f515a..f801f80 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password -DDNS_PUBLIC_BASE_URL=https://ddns.example.net -DDNS_HOSTNAME_SUFFIX=ddns.example.net +DDNS_PUBLIC_BASE_URL=https://karldns.de +DDNS_HOSTNAME_SUFFIX=karldns.de DDNS_DATABASE=/data/ddns.sqlite3 -DDNS_TRUSTED_HOSTS=ddns.example.net +DDNS_TRUSTED_HOSTS=karldns.de DDNS_DNS_PROVIDER=cloudflare -DDNS_DNS_ZONES=ddns.example.net +DDNS_DNS_ZONES=karldns.de DDNS_REQUIRE_DNS_PROVIDER=1 DDNS_CLOUDFLARE_API_TOKEN=change-me-cloudflare-token DDNS_CLOUDFLARE_ZONE_ID=change-me-cloudflare-zone-id diff --git a/.gitignore b/.gitignore index 64aef47..1f2ad57 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ dist/ build/ *.egg-info/ +tmp/ diff --git a/Caddyfile.example b/Caddyfile.example index 0b9961c..2607729 100644 --- a/Caddyfile.example +++ b/Caddyfile.example @@ -1,4 +1,4 @@ -ddns.example.net { +karldns.de { encode zstd gzip reverse_proxy ddns:8080 } diff --git a/README.md b/README.md index e2d286b..f4428b5 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# RouterPulse +# KarlDNS **Self-hosted DynDNS for FRITZ!Box, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and any router that can call a custom DDNS update URL.** -RouterPulse is the friendly name for `router-dyndns`: a small FastAPI Dynamic DNS provider you can run on your own VPS. It gives users FRITZ!Box-ready DynDNS credentials, keeps public A/AAAA records updated through Cloudflare or RFC 2136, provides generated hostnames that work as CNAME targets, and supports 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 FRITZ!Box-ready DynDNS credentials, keeps public A/AAAA records updated through Cloudflare or RFC 2136, provides generated hostnames that work as CNAME targets, and supports verified custom domains with DNS TXT ownership checks. -If you want a lightweight DuckDNS / Dynu / No-IP style service that you control yourself, RouterPulse is built for that use case. +If you want a lightweight DuckDNS / Dynu / No-IP style service that you control yourself, KarlDNS is built for that use case. -![RouterPulse self-hosted DynDNS home screen](docs/screenshots/home.png) +![KarlDNS self-hosted DynDNS home screen](docs/screenshots/home.png) -## Why RouterPulse? +## Why KarlDNS? - **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure. - **FRITZ!Box compatible**: copy the generated `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` straight into the German FRITZ!Box DynDNS form. @@ -31,13 +31,13 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control ## What It Does -RouterPulse lets you host your own managed DynDNS service: +KarlDNS lets you host your own managed DynDNS service: -1. A user opens your RouterPulse URL. -2. They generate a random hostname like `a1b2c3d4.ddns.example.net`, or verify their own domain with a TXT record. -3. RouterPulse shows the exact router settings for the DynDNS form. +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. +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. RouterPulse publishes the matching DNS records through your configured DNS backend. +5. KarlDNS publishes the matching DNS records through your configured DNS backend. 6. If the user wants their own DNS name, they can create a CNAME to the generated hostname. That means users always have a current hostname for VPN, remote access, self-hosted apps, home servers, NAS devices, and lab networks. @@ -81,10 +81,10 @@ Run locally: ```bash export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value' export DDNS_PUBLIC_BASE_URL='http://localhost:8080' -export DDNS_HOSTNAME_SUFFIX='ddns.example.net' +export DDNS_HOSTNAME_SUFFIX='karldns.de' export DDNS_DATABASE='./ddns.sqlite3' -router-dyndns serve --host 127.0.0.1 --port 8080 +karldns serve --host 127.0.0.1 --port 8080 ``` Open: @@ -103,12 +103,12 @@ The public OpenAPI schema documents the developer API under `/api/v1`. Admin/ope For an internet-facing DynDNS service, run behind HTTPS and require a real DNS backend: ```bash -export DDNS_PUBLIC_BASE_URL='https://ddns.example.net' -export DDNS_HOSTNAME_SUFFIX='ddns.example.net' +export DDNS_PUBLIC_BASE_URL='https://karldns.de' +export DDNS_HOSTNAME_SUFFIX='karldns.de' export DDNS_DATABASE='/data/ddns.sqlite3' -export DDNS_TRUSTED_HOSTS='ddns.example.net' +export DDNS_TRUSTED_HOSTS='karldns.de' export DDNS_REQUIRE_DNS_PROVIDER=1 -export DDNS_DNS_ZONES='ddns.example.net' +export DDNS_DNS_ZONES='karldns.de' export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1' export DDNS_RATE_LIMIT_PER_MINUTE=60 export DDNS_ADMIN_RATE_LIMIT_PER_MINUTE=20 @@ -127,14 +127,14 @@ export DDNS_CLOUDFLARE_ZONE_ID='replace-with-cloudflare-zone-id' export DDNS_TTL=60 ``` -Use a least-privilege Cloudflare API token limited to the zone RouterPulse should update. +Use a least-privilege Cloudflare API token limited to the zone KarlDNS should update. ### RFC 2136 DNS Backend ```bash export DDNS_DNS_PROVIDER='rfc2136' export DDNS_RFC2136_SERVER='127.0.0.1' -export DDNS_RFC2136_ZONE='ddns.example.net' +export DDNS_RFC2136_ZONE='karldns.de' export DDNS_RFC2136_KEY_NAME='ddns-key' export DDNS_RFC2136_KEY_SECRET='replace-with-tsig-secret' export DDNS_TTL=60 @@ -146,7 +146,7 @@ export DDNS_TTL=60 Create a hostname in the web UI. For the normal accountless flow, the generated `Update-URL` already contains the secret update slug. Put that URL into the router and use the generated hostname as `Domainnamen`. -RouterPulse also shows optional compatibility fields for routers that insist on separate credentials: +KarlDNS also shows optional compatibility fields for routers that insist on separate credentials: - `Update-URL` - `Domainnamen` @@ -157,17 +157,17 @@ RouterPulse also shows optional compatibility fields for routers that insist on The generated FRITZ!Box URL uses the native placeholders: ```text -https://ddns.example.net/u/?myip=&myipv6= +https://karldns.de/u/?myip=&myipv6= ``` -FRITZ!Box replaces `` and `` with the current WAN IPv4/IPv6 address and sends an HTTP GET request to that URL whenever it detects an address change. RouterPulse returns DynDNS-style responses such as `good 203.0.113.10`, `nochg 203.0.113.10`, `badauth`, or `nohost`. +FRITZ!Box replaces `` and `` with the current WAN IPv4/IPv6 address and sends an HTTP GET request to that URL whenever it detects an address change. KarlDNS returns DynDNS-style responses such as `good 203.0.113.10`, `nochg 203.0.113.10`, `badauth`, or `nohost`. -This URL is enough for routers that only expose a single update URL field and do not send separate credentials. RouterPulse also shows a `One-box update URL` that embeds `hostname`, `username`, and `password` as query parameters for clients that require credential-style DynDNS URLs but do not expose separate credential fields. Treat both URLs as secrets. +This URL is enough for routers that only expose a single update URL field and do not send separate credentials. KarlDNS also shows a `One-box update URL` that embeds `hostname`, `username`, and `password` as query parameters for clients that require credential-style DynDNS URLs but do not expose separate credential fields. Treat both URLs as secrets. The compatibility endpoint is also available: ```text -https://ddns.example.net/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. @@ -178,30 +178,30 @@ Every generated hostname can be updated with curl, cron, a shell script, Home As ```bash # Explicit IPv4 update -curl -sS 'https://ddns.example.net/u/?myip=203.0.113.10' +curl -sS 'https://karldns.de/u/?myip=203.0.113.10' # Explicit IPv6 update -curl -sS 'https://ddns.example.net/u/?myipv6=2001:db8::10' +curl -sS 'https://karldns.de/u/?myipv6=2001:db8::10' # Use the request source IP -curl -sS 'https://ddns.example.net/u/' +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://ddns.example.net/nic/update?hostname=home.example.net&username=router&password=&myip=203.0.113.10' +curl -sS 'https://karldns.de/nic/update?hostname=home.example.net&username=router&password=&myip=203.0.113.10' ``` ## Use Your Own Subdomain With CNAME -Most users do not need domain verification. Generate a RouterPulse hostname first, then create a CNAME at your DNS provider: +Most users do not need domain verification. Generate a KarlDNS hostname first, then create a CNAME at your DNS provider: ```dns -home.example.com. CNAME a1b2c3d4.ddns.example.net. +home.example.com. CNAME a1b2c3d4.karldns.de. ``` -Put the RouterPulse `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` into the router. The router updates `a1b2c3d4.ddns.example.net`; `home.example.com` follows through the CNAME. +Put the KarlDNS `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` into the router. The router updates `a1b2c3d4.karldns.de`; `home.example.com` follows through the CNAME. 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. @@ -209,7 +209,7 @@ The generated hostname is not a secret. Treat it like any public DNS name. The u ## Custom Domain Flow -This flow is only needed when RouterPulse should publish A/AAAA records directly inside a user-owned domain instead of using a CNAME target. +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: @@ -219,7 +219,7 @@ Custom-domain publishing is intentionally simple: 4. Press **I added it, check DNS**. 5. Create router credentials after the TXT record verifies. -RouterPulse stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. The private claim link is the bearer credential for returning later after DNS propagation. +KarlDNS stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. The private claim link is the bearer credential for returning later after DNS propagation. ## HTTP API @@ -238,7 +238,7 @@ The JSON API is available under `/api/v1`. Example: ```bash -curl -sS https://ddns.example.net/api/v1/hostnames/magic \ +curl -sS https://karldns.de/api/v1/hostnames/magic \ -H 'content-type: application/json' \ -d '{"username":"home-router"}' ``` @@ -260,7 +260,7 @@ docker compose up -d --build - Keep `.env` out of git. - Use a long random value for `DDNS_ADMIN_PASSWORD`. - Use a least-privilege DNS API token or TSIG key. -- Set `DDNS_TRUSTED_HOSTS` to the public hostnames your reverse proxy will forward, for example `ddns.example.net`. +- Set `DDNS_TRUSTED_HOSTS` to the public hostnames your reverse proxy will forward, for example `karldns.de`. - Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers. - Keep `/docs` and `/redoc` available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth. - Leave cleanup enabled. The app periodically removes abandoned TXT challenges and generated hostnames that were never updated by a router. @@ -282,12 +282,12 @@ Security and cleanup defaults: SQLite backup example: ```bash -sqlite3 /data/ddns.sqlite3 ".backup '/backups/routerpulse.sqlite3'" +sqlite3 /data/ddns.sqlite3 ".backup '/backups/karldns.sqlite3'" ``` ## Project Status -RouterPulse is suitable for self-hosting and private/public beta use when a real DNS backend is configured. For a larger public free DynDNS provider, add external abuse controls, monitoring, alerting, and a tested backup/restore process before launch. +KarlDNS is suitable for self-hosting and private/public beta use when a real DNS backend is configured. For a larger public free DynDNS provider, add external abuse controls, monitoring, alerting, and a tested backup/restore process before launch. ## Development diff --git a/docs/screenshots/domain-verification.png b/docs/screenshots/domain-verification.png index 65fc014..a125ace 100644 Binary files a/docs/screenshots/domain-verification.png and b/docs/screenshots/domain-verification.png differ diff --git a/docs/screenshots/generated-credentials.png b/docs/screenshots/generated-credentials.png index 9aef081..02fb1f6 100644 Binary files a/docs/screenshots/generated-credentials.png and b/docs/screenshots/generated-credentials.png differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png index 7a112bb..56646be 100644 Binary files a/docs/screenshots/home.png and b/docs/screenshots/home.png differ diff --git a/pyproject.toml b/pyproject.toml index 4f0c4e6..66d108f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.25"] build-backend = "hatchling.build" [project] -name = "router-dyndns" +name = "karldns" version = "0.1.0" description = "A small self-hosted DynDNS service for FRITZ!Box and compatible routers." readme = "README.md" @@ -39,6 +39,7 @@ dev = [ ] [project.scripts] +karldns = "router_dyndns.cli:main" router-dyndns = "router_dyndns.cli:main" [tool.hatch.build.targets.wheel] diff --git a/router_dyndns/assets/favicon.ico b/router_dyndns/assets/favicon.ico index 2a758c4..368533f 100644 Binary files a/router_dyndns/assets/favicon.ico and b/router_dyndns/assets/favicon.ico differ diff --git a/router_dyndns/assets/logo-192.png b/router_dyndns/assets/logo-192.png index 94d05f5..7d98bf5 100644 Binary files a/router_dyndns/assets/logo-192.png and b/router_dyndns/assets/logo-192.png differ diff --git a/router_dyndns/assets/logo-512.png b/router_dyndns/assets/logo-512.png index b26a73e..c8918d1 100644 Binary files a/router_dyndns/assets/logo-512.png and b/router_dyndns/assets/logo-512.png differ diff --git a/router_dyndns/assets/logo.png b/router_dyndns/assets/logo.png index 1985155..32f887a 100644 Binary files a/router_dyndns/assets/logo.png and b/router_dyndns/assets/logo.png differ diff --git a/router_dyndns/assets/logo.svg b/router_dyndns/assets/logo.svg deleted file mode 100644 index 136e928..0000000 --- a/router_dyndns/assets/logo.svg +++ /dev/null @@ -1,35 +0,0 @@ - - router-dyndns logo - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/router_dyndns/cli.py b/router_dyndns/cli.py index 55a7812..26056f8 100644 --- a/router_dyndns/cli.py +++ b/router_dyndns/cli.py @@ -6,7 +6,7 @@ from .ddns import DdnsSettings, make_app def main() -> None: - parser = argparse.ArgumentParser(prog="router-dyndns") + parser = argparse.ArgumentParser(prog="karldns") subparsers = parser.add_subparsers(dest="command", required=True) render_url = subparsers.add_parser("render-fritz-url", help="Render a FRITZ!Box custom DynDNS update URL.") diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index 89fe939..28b378f 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -229,7 +229,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: await cleanup_task app = FastAPI( - title="router_dyndns DDNS", + 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 " @@ -301,7 +301,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: raise HTTPException( status_code=401, detail="admin authentication required", - headers={"WWW-Authenticate": 'Basic realm="router_dyndns-ddns"'}, + headers={"WWW-Authenticate": 'Basic realm="karldns"'}, ) def apply_update( @@ -521,10 +521,6 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: def favicon() -> FileResponse: return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon") - @app.get("/logo.svg", include_in_schema=False) - def logo_svg() -> FileResponse: - return FileResponse(ASSET_DIR / "logo.svg", media_type="image/svg+xml") - @app.get("/logo.png", include_in_schema=False) def logo_png() -> FileResponse: return FileResponse(ASSET_DIR / "logo.png", media_type="image/png") @@ -647,13 +643,13 @@ def _render_public_home( message_html = _message_band(message) if message and not challenge else "" challenge_html = _custom_domain_flow(service, challenge, message) return _page( - "router-dyndns", + "KarlDNS", f"""
{_top_nav()}
- +

Dynamic DNS for FRITZ!Box and any router

The simplest way to keep a home IP updated.

@@ -677,7 +673,7 @@ def _render_public_home(

The generated URL is the secret. FRITZ!Box replaces <ipaddr> and calls it when your public IP changes.

@@ -698,9 +694,9 @@ def _render_public_home(
# explicit IPv4 - curl -sS 'https://ddns.example.net/u/<secret>?myip=203.0.113.10' + curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10' # source IP fallback - curl -sS 'https://ddns.example.net/u/<secret>' + curl -sS 'https://karldns.de/u/<secret>'
@@ -727,7 +723,7 @@ def _top_nav(label: str | None = None, links: str = "") -> str: