From 148918b283dfb2d1029d22b319a0140f9537015e Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 17:57:42 +0200 Subject: [PATCH] Remove account-based DynDNS onboarding --- README.md | 2 +- router_dyndns/ddns.py | 288 ++------------------------------- router_dyndns/ddns_api.py | 19 +-- router_dyndns/ddns_models.py | 6 - router_dyndns/ddns_security.py | 53 +----- router_dyndns/ddns_service.py | 44 ++--- router_dyndns/ddns_store.py | 181 ++++----------------- tests/test_ddns.py | 73 +-------- 8 files changed, 67 insertions(+), 599 deletions(-) diff --git a/README.md b/README.md index b387b50..9f3eeb0 100644 --- a/README.md +++ b/README.md @@ -59,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 | Private magic links for hostnames and custom-domain claims; optional account workspace for dashboards | +| Auth model | No user accounts. Private bearer links for hostnames and custom-domain claims | | Operations | Rate limiting, update logs, cleanup jobs, Docker, Caddy example | ## Quick Start diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index e6d76c0..bb1e2a8 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -19,10 +19,7 @@ from .ddns_security import ( client_ip, credentials_from_basic_auth, is_rate_limited_path, - normalize_email, require_admin_csrf, - set_session_cookie, - verify_session_cookie, ) from .ddns_service import DdnsService, normalize_domain, normalize_hostname from .ddns_store import DdnsStore @@ -67,8 +64,6 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: settings = settings or DdnsSettings.from_env() if not settings.admin_password and settings.shared_secret: settings = settings.model_copy(update={"admin_password": settings.shared_secret}) - if not settings.session_secret: - settings = settings.model_copy(update={"session_secret": settings.admin_password or settings.shared_secret}) if settings.require_dns_provider and not dns_backend_configured(settings): raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured") @@ -139,10 +134,6 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: headers={"WWW-Authenticate": 'Basic realm="router_dyndns-ddns"'}, ) - def current_user(request: Request) -> dict[str, str | int] | None: - user_id = verify_session_cookie(request.cookies.get("ddns_session"), settings.session_secret) - return store.get_user(user_id) if user_id is not None else None - def apply_update( request: Request, hostname: str, @@ -157,21 +148,14 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: return PlainTextResponse("911 dns publish failed", status_code=500) @app.get("/", response_class=HTMLResponse) - def index(request: Request) -> str: - user = current_user(request) - if user: - return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, None) + def index() -> str: return _render_public_home(service, settings, None, None) - @app.get("/login", response_class=HTMLResponse) - def login_page() -> str: - return _render_auth_page(settings, None) - @app.post("/magic", response_class=HTMLResponse) async def magic_account(request: Request) -> str: form = _parse_form(await request.body()) username = _first_form_value(form, "username") or None - account = await run_in_threadpool(service.create_managed_account, username, None) + account = await run_in_threadpool(service.create_managed_account, username) return _render_public_home(service, settings, account, None) @app.get("/m/{management_slug}", response_class=HTMLResponse) @@ -218,53 +202,34 @@ 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) form = _parse_form(await request.body()) try: 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 - 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) form = _parse_form(await request.body()) domain = normalize_domain(_first_form_value(form, "domain")) claim_secret = _first_form_value(form, "claim_secret") - 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) + found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret) 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, - 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) + return _render_public_home(service, settings, None, message, challenge) + return _render_public_home(service, settings, 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) 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"]) if user else None) + account = await run_in_threadpool(service.create_managed_account, username) else: claim_secret = _first_form_value(form, "claim_secret") account = await run_in_threadpool( @@ -272,50 +237,12 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: _first_form_value(form, "hostname"), claim_secret, username, - 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") - async def register(request: Request) -> Response: - form = _parse_form(await request.body()) - email = normalize_email(_first_form_value(form, "email")) - password = _first_form_value(form, "password") - invite = _first_form_value(form, "invite") - if not email or len(password) < 12: - return HTMLResponse(_render_auth_page(settings, "Use a valid email and a password with at least 12 characters.")) - try: - user = await run_in_threadpool(store.create_user, email, password, invite, settings.require_invite) - except (sqlite3.IntegrityError, ValueError): - return HTMLResponse( - _render_auth_page(settings, "Registration failed. Check the invite code and email address."), - status_code=400, - ) - response = RedirectResponse("/", status_code=303) - set_session_cookie(response, settings, int(user["id"])) - return response - - @app.post("/login") - async def login(request: Request) -> Response: - form = _parse_form(await request.body()) - user = await run_in_threadpool(store.authenticate_user, normalize_email(_first_form_value(form, "email")), _first_form_value(form, "password")) - if not user: - return HTMLResponse(_render_auth_page(settings, "Login failed."), status_code=401) - response = RedirectResponse("/", status_code=303) - set_session_cookie(response, settings, int(user["id"])) - return response - - @app.post("/logout") - def logout() -> Response: - response = RedirectResponse("/", status_code=303) - response.delete_cookie("ddns_session") - return response + return _render_public_home(service, settings, account, None) @app.get("/admin", response_class=HTMLResponse) def admin(_: None = Depends(authenticate_admin)) -> str: - return _render_admin_page(service, settings, store.list_accounts(), store.list_invites(), None) + return _render_admin_page(service, settings, store.list_accounts(), None) @app.post("/admin/accounts") async def admin_create_account(request: Request, _: None = Depends(authenticate_admin)) -> Response: @@ -331,7 +258,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: account = store.create_account(hostname, username) except sqlite3.IntegrityError as exc: raise HTTPException(status_code=409, detail="hostname already exists") from exc - return HTMLResponse(_render_admin_page(service, settings, store.list_accounts(), store.list_invites(), account)) + return HTMLResponse(_render_admin_page(service, settings, store.list_accounts(), account)) @app.post("/admin/accounts/delete") async def admin_delete_account(request: Request, _: None = Depends(authenticate_admin)) -> Response: @@ -345,13 +272,6 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500) return RedirectResponse("/admin", status_code=303) - @app.post("/admin/invites") - async def create_invite(request: Request, _: None = Depends(authenticate_admin)) -> Response: - form = _parse_form(await request.body()) - require_admin_csrf(settings, _first_form_value(form, "csrf")) - store.create_invite() - return RedirectResponse("/admin", status_code=303) - @app.get("/records") def records(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]: return store.list_records() @@ -423,16 +343,6 @@ def _first_form_value(form: dict[str, list[str]], key: str) -> str: return form.get(key, [""])[0].strip() -def _render_magic_page( - service: DdnsService, - settings: DdnsSettings, - created_account: dict[str, str] | None, - message: str | None, - challenge: dict[str, str | None] | None = None, -) -> str: - return _render_public_home(service, settings, created_account, message, challenge) - - def _render_public_home( service: DdnsService, settings: DdnsSettings, @@ -490,138 +400,11 @@ def _render_public_home( ) -def _render_user_home( - service: DdnsService, - settings: DdnsSettings, - user: dict[str, str | int], - accounts: list[dict[str, str | int | None]], - created_account: dict[str, str] | None, - message: str | None, - challenge: dict[str, str | None] | None = None, -) -> str: - created_html = _credentials_panel(service, created_account) - message_html = _message_band(message) if message and not challenge else "" - account_rows = "\n".join(_account_row(account, show_owner=False) for account in accounts) or """ - No hostnames yet. - """ - return _page( - "router-dyndns dashboard", - f""" -
- {_top_nav(str(user["email"]), True)} -
-
-

Dashboard

-

Your router hostnames

-

Create provider hostnames immediately, or add a domain and verify it with a DNS TXT record.

-
-
- {message_html} - {created_html} -
-
-
-

Provider hostname

-

Create a random hostname

-

Best for simple FRITZ!Box setups. The hostname and update link are generated for you.

-
-
- - - -
-
-
- {_custom_domain_flow(service, challenge, message)} -
-
-
-
-

Inventory

-

Hostnames

-
-
-
- - - {account_rows} -
DomainUserIPv4IPv6Updated
-
-
-
-
- """, - ) - - -def _render_auth_page(settings: DdnsSettings, message: str | None) -> str: - invite_field = """ - - """ if settings.require_invite else "" - return _page( - "Sign in", - f""" -
- {_top_nav()} -
-
-

Custom domains

-

Optional account workspace.

-

Most users do not need this. Magic links handle normal hostname and domain setup; accounts are only for people who want a persistent dashboard.

-
-
- {_message_band(message) if message else ""} -
-
-
-

Login

-
- - - -
-
-
-

Register

-
- - - {invite_field} - -
-
-
-
-
- """, - ) - - -def _render_public_page( - service: DdnsService, - settings: DdnsSettings, - user: dict[str, str | int] | None, - accounts: list[dict[str, str | int | None]], - challenge: dict[str, str | None] | None, - message: str | None, - created_account: dict[str, str] | None = None, -) -> str: - if user: - return _render_user_home(service, settings, user, accounts, created_account, message, challenge) - return _render_public_home(service, settings, created_account, message, challenge) - - -def _top_nav(label: str | None = None, show_logout: bool = False, links: str = "") -> str: +def _top_nav(label: str | None = None, links: str = "") -> str: account_html = "" if label: account_html = f'{html.escape(label)}' - if show_logout: - account_html += '
' - elif not links: + if not links: account_html += 'Domain setup' return f"""