diff --git a/README.md b/README.md index ea31659..b387b50 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,14 @@ 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. -- **No account required for basic hostnames**: generate a cryptographically random provider hostname and private management link. -- **Custom domain support**: users enter a domain, add a DNS TXT record, then press a button to verify ownership before credentials are issued. +- **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. - **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`. ## Screenshots -### Dashboard - -![RouterPulse dashboard for router hostnames](docs/screenshots/dashboard.png) - ### Generate Router Credentials ![Generated FRITZ!Box DynDNS credentials](docs/screenshots/generated-credentials.png) @@ -63,7 +59,7 @@ That means users always have a current hostname for VPN, remote access, self-hos | Custom domains | TXT challenge verification before hostname creation | | Persistence | SQLite with WAL mode | | API | FastAPI, OpenAPI, Swagger UI, ReDoc | -| Auth model | Anonymous magic links for simple hostnames, login workspace for custom domains | +| Auth model | Private magic links for hostnames and custom-domain claims; optional account workspace for dashboards | | Operations | Rate limiting, update logs, cleanup jobs, Docker, Caddy example | ## Quick Start @@ -162,11 +158,12 @@ Prefer `/u/` for managed service use because the router request cannot cha Custom domains are intentionally simple: 1. Enter the domain. -2. Add the generated TXT record at your DNS provider. -3. Press **I added it, check DNS**. -4. Create router credentials after the TXT record verifies. +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. -RouterPulse stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. +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. ## HTTP API @@ -177,9 +174,9 @@ 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. Requires login. | -| `POST /api/v1/domains/verify` | Verify a TXT challenge. Requires login. | -| `POST /api/v1/hostnames/custom` | Create a custom hostname below a verified domain. Requires login. | +| `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: diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png deleted file mode 100644 index 8debd08..0000000 Binary files a/docs/screenshots/dashboard.png and /dev/null differ diff --git a/docs/screenshots/domain-verification.png b/docs/screenshots/domain-verification.png index 8b671b2..65fc014 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 0dbd940..9aef081 100644 Binary files a/docs/screenshots/generated-credentials.png and b/docs/screenshots/generated-credentials.png differ diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index f567deb..e6d76c0 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -181,6 +181,13 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: raise HTTPException(status_code=404, detail="not found") return _render_management_page(service, settings, management_slug, account) + @app.get("/d/{claim_secret}", response_class=HTMLResponse) + def manage_domain_claim(claim_secret: str) -> str: + challenge = store.get_domain_challenge_by_secret(claim_secret) + if not challenge: + raise HTTPException(status_code=404, detail="not found") + return _render_public_home(service, settings, None, None, challenge) + @app.post("/m/{management_slug}/delete") def delete_magic(management_slug: str) -> Response: account = store.get_account_by_management_slug(management_slug) @@ -212,46 +219,52 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: @app.post("/request-domain", response_class=HTMLResponse) async def request_domain(request: Request) -> str: user = await run_in_threadpool(current_user, request) - if not user: - raise HTTPException(status_code=401, detail="authentication required") form = _parse_form(await request.body()) try: - challenge = await run_in_threadpool(service.create_domain_challenge, _first_form_value(form, "domain"), int(user["id"])) + challenge = await run_in_threadpool( + service.create_domain_challenge, + _first_form_value(form, "domain"), + int(user["id"]) if user else None, + ) except sqlite3.IntegrityError as exc: raise HTTPException(status_code=409, detail="domain is already verified") from exc - return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, challenge) + if user: + return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, challenge) + return _render_public_home(service, settings, None, None, challenge) @app.post("/verify-domain", response_class=HTMLResponse) async def verify_domain(request: Request) -> str: user = await run_in_threadpool(current_user, request) - if not user: - raise HTTPException(status_code=401, detail="authentication required") form = _parse_form(await request.body()) 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, int(user["id"])) + owner_user_id = int(user["id"]) if user else None + found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, owner_user_id) if not found: + message = "TXT record not found yet. DNS propagation can take a few minutes." + if not user: + return _render_public_home(service, settings, None, message, challenge) return _render_user_home( service, settings, user, store.list_accounts(int(user["id"])), None, - "TXT record not found yet. DNS propagation can take a few minutes.", + message, challenge, ) + if not user: + return _render_public_home(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge) return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, "Domain verified. You can now create a hostname under it.", challenge) @app.post("/accounts", response_class=HTMLResponse) async def self_service_account(request: Request) -> str: user = await run_in_threadpool(current_user, request) - if not user: - raise HTTPException(status_code=401, detail="authentication required") form = _parse_form(await request.body()) mode = _first_form_value(form, "mode") username = _first_form_value(form, "username") or None if mode == "managed": - account = await run_in_threadpool(service.create_managed_account, username, int(user["id"])) + account = await run_in_threadpool(service.create_managed_account, username, int(user["id"]) if user else None) else: claim_secret = _first_form_value(form, "claim_secret") account = await run_in_threadpool( @@ -259,8 +272,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: _first_form_value(form, "hostname"), claim_secret, username, - int(user["id"]), + int(user["id"]) if user else None, ) + if not user: + return _render_public_home(service, settings, account, None) return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), account, None, None) @app.post("/register") @@ -428,6 +443,7 @@ def _render_public_home( suffix = html.escape(settings.hostname_suffix or "your DynDNS domain") 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) return _page( "router-dyndns", f""" @@ -440,7 +456,7 @@ def _render_public_home(

Create a secure update endpoint under {suffix}. Provider hostnames are anonymous; custom domains require DNS proof.

Create hostname - Use custom domain + Use custom domain
@@ -461,16 +477,7 @@ def _render_public_home( -
-
-
-

Custom domains

-

Use your own domain after TXT verification.

-

Enter your domain, add the TXT record we show you, then press the check button. We verify public DNS before credentials are issued.

-
- Set up domain -
-
+ {challenge_html}