2026-05-20 13:48:24 +02:00
from __future__ import annotations
2026-05-20 18:01:29 +02:00
import asyncio
import contextlib
2026-05-20 13:48:24 +02:00
import html
import secrets
import sqlite3
import urllib.parse
2026-05-20 18:01:29 +02:00
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
2026-05-20 14:29:07 +02:00
from pathlib import Path
2026-05-20 13:48:24 +02:00
from typing import Annotated
from fastapi import Depends , FastAPI , Header , HTTPException , Query , Request , Response
2026-05-20 14:29:07 +02:00
from fastapi.responses import FileResponse , HTMLResponse , PlainTextResponse , RedirectResponse
2026-05-20 13:48:24 +02:00
from starlette.concurrency import run_in_threadpool
2026-05-20 18:17:20 +02:00
from starlette.types import ASGIApp , Message , Receive , Scope , Send
2026-05-20 13:48:24 +02:00
from .ddns_api import create_api_router
from .ddns_dns import delete_dns_records , dns_backend_configured , publish_dns
from .ddns_models import DdnsSettings , UpdateResult
from .ddns_security import (
admin_csrf_token ,
client_ip ,
credentials_from_basic_auth ,
is_rate_limited_path ,
require_admin_csrf ,
)
from .ddns_service import DdnsService , normalize_domain , normalize_hostname
from .ddns_store import DdnsStore
__all__ = [
"DdnsSettings" ,
"DdnsStore" ,
"UpdateResult" ,
"delete_dns_records" ,
"make_app" ,
"publish_dns" ,
]
OPENAPI_TAGS = [
{
"name" : "hostnames" ,
"description" : "Create provider-owned or verified custom DynDNS hostnames." ,
},
{
"name" : "domains" ,
"description" : "Issue and verify DNS TXT challenges for custom domains." ,
},
{
"name" : "updates" ,
"description" : "Router-facing IP update endpoints for FRITZ!Box and compatible clients." ,
},
{
"name" : "management" ,
"description" : "Bearer-link management for generated hostnames." ,
},
]
2026-05-20 14:29:07 +02:00
ASSET_DIR = Path ( __file__ ) . with_name ( "assets" )
2026-05-20 13:48:24 +02:00
2026-05-20 18:17:20 +02:00
class RequestBodyTooLargeError ( Exception ):
pass
class RequestBodyLimitMiddleware :
def __init__ ( self , app : ASGIApp , max_body_bytes : int ) -> None :
self . app = app
self . max_body_bytes = max_body_bytes
async def __call__ ( self , scope : Scope , receive : Receive , send : Send ) -> None :
if scope [ "type" ] != "http" :
await self . app ( scope , receive , send )
return
received = 0
async def limited_receive () -> Message :
nonlocal received
message = await receive ()
if message [ "type" ] == "http.request" :
received += len ( message . get ( "body" , b "" ))
if received > self . max_body_bytes :
raise RequestBodyTooLargeError
return message
try :
await self . app ( scope , limited_receive , send )
except RequestBodyTooLargeError :
response = PlainTextResponse ( "request body too large" , status_code = 413 )
await response ( scope , receive , send )
2026-05-20 13:48:24 +02:00
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 settings . require_dns_provider and not dns_backend_configured ( settings ):
raise RuntimeError ( "DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured" )
store = DdnsStore ( settings . database_path )
2026-05-20 18:01:29 +02:00
store . cleanup ( settings . cleanup_challenge_hours , settings . cleanup_unused_account_hours )
2026-05-20 13:48:24 +02:00
service = DdnsService ( settings , store )
2026-05-20 18:01:29 +02:00
@asynccontextmanager
async def lifespan ( _ : FastAPI ) -> AsyncIterator [ None ]:
cleanup_task = asyncio . create_task ( _cleanup_loop ( store , settings ))
try :
yield
finally :
cleanup_task . cancel ()
with contextlib . suppress ( asyncio . CancelledError ):
await cleanup_task
2026-05-20 13:48:24 +02:00
app = FastAPI (
title = "router_dyndns DDNS" ,
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."
),
version = "0.1.0" ,
docs_url = "/docs" ,
redoc_url = "/redoc" ,
openapi_tags = OPENAPI_TAGS ,
2026-05-20 18:01:29 +02:00
lifespan = lifespan ,
2026-05-20 13:48:24 +02:00
)
2026-05-20 18:17:20 +02:00
app . add_middleware ( RequestBodyLimitMiddleware , max_body_bytes = settings . max_request_body_bytes )
2026-05-20 13:48:24 +02:00
app . include_router ( create_api_router ( settings , store , service ))
@app.middleware ( "http" )
async def security_middleware ( request : Request , call_next ):
2026-05-20 18:17:20 +02:00
try :
content_length = int ( request . headers . get ( "content-length" , "0" ) or "0" )
except ValueError :
content_length = 0
if content_length > settings . max_request_body_bytes :
return PlainTextResponse ( "request body too large" , status_code = 413 )
if is_rate_limited_path ( request . url . path ) or _is_admin_path ( request . url . path ):
limit = settings . admin_rate_limit_per_minute if _is_admin_path ( request . url . path ) else settings . rate_limit_per_minute
2026-05-20 13:48:24 +02:00
key = f " { client_ip ( request , settings ) } : { request . url . path } "
2026-05-20 18:17:20 +02:00
allowed = await run_in_threadpool ( store . allow_rate_limit , key , limit )
2026-05-20 13:48:24 +02:00
if not allowed :
return PlainTextResponse ( "rate limit exceeded" , status_code = 429 )
response = await call_next ( request )
2026-05-20 18:17:20 +02:00
if _is_secret_response ( request ):
response . headers [ "Cache-Control" ] = "no-store"
response . headers [ "Pragma" ] = "no-cache"
2026-05-20 13:48:24 +02:00
response . headers [ "X-Content-Type-Options" ] = "nosniff"
response . headers [ "X-Frame-Options" ] = "DENY"
response . headers [ "Referrer-Policy" ] = "same-origin"
response . headers [ "Permissions-Policy" ] = "geolocation=(), microphone=(), camera=()"
response . headers [ "Content-Security-Policy" ] = (
2026-05-20 14:08:15 +02:00
"default-src 'self'; script-src 'unsafe-inline' 'self'; style-src 'unsafe-inline' 'self'; form-action 'self'; frame-ancestors 'none'"
2026-05-20 13:48:24 +02:00
)
if settings . public_base_url . startswith ( "https://" ):
response . headers [ "Strict-Transport-Security" ] = "max-age=31536000; includeSubDomains"
return response
def authenticate_update (
authorization : Annotated [ str | None , Header ()] = None ,
username : str | None = Query ( default = None ),
password : str | None = Query ( default = None ),
pass_ : str | None = Query ( default = None , alias = "pass" ),
passwd : str | None = Query ( default = None ),
) -> tuple [ str | None , str | None ]:
supplied_user = username
supplied_token = password or pass_ or passwd
if authorization and authorization . lower () . startswith ( "basic " ):
supplied_user , supplied_token = credentials_from_basic_auth ( authorization )
return supplied_user , supplied_token
def authenticate_admin (
authorization : Annotated [ str | None , Header ()] = None ,
) -> None :
if not settings . admin_password :
raise HTTPException ( status_code = 500 , detail = "DDNS_ADMIN_PASSWORD is required" )
supplied = None
if authorization and authorization . lower () . startswith ( "basic " ):
_ , supplied = credentials_from_basic_auth ( authorization )
if not supplied or not secrets . compare_digest ( supplied , settings . admin_password ):
raise HTTPException (
status_code = 401 ,
detail = "admin authentication required" ,
headers = { "WWW-Authenticate" : 'Basic realm="router_dyndns-ddns"' },
)
def apply_update (
request : Request ,
hostname : str ,
myip : str | None ,
ipaddr : str | None ,
myipv6 : str | None ,
ip6addr : str | None ,
) -> str | PlainTextResponse :
try :
return service . apply_update ( request , hostname , myip , ipaddr , myipv6 , ip6addr ) . result . dyndns_response
except Exception :
return PlainTextResponse ( "911 dns publish failed" , status_code = 500 )
@app.get ( "/" , response_class = HTMLResponse )
2026-05-20 17:57:42 +02:00
def index () -> str :
2026-05-20 14:08:15 +02:00
return _render_public_home ( service , settings , None , None )
2026-05-20 13:48:24 +02:00
@app.post ( "/magic" , response_class = HTMLResponse )
async def magic_account ( request : Request ) -> str :
2026-05-20 18:17:20 +02:00
form = await _request_form ( request , settings )
2026-05-20 13:48:24 +02:00
username = _first_form_value ( form , "username" ) or None
2026-05-20 17:57:42 +02:00
account = await run_in_threadpool ( service . create_managed_account , username )
2026-05-20 14:08:15 +02:00
return _render_public_home ( service , settings , account , None )
2026-05-20 13:48:24 +02:00
@app.get ( "/m/ {management_slug} " , response_class = HTMLResponse )
def manage_magic ( management_slug : str ) -> str :
account = store . get_account_by_management_slug ( management_slug )
if not account or account [ "disabled" ]:
raise HTTPException ( status_code = 404 , detail = "not found" )
return _render_management_page ( service , settings , management_slug , account )
2026-05-20 17:46:10 +02:00
@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 )
2026-05-20 13:48:24 +02:00
@app.post ( "/m/ {management_slug} /delete" )
def delete_magic ( management_slug : str ) -> Response :
account = store . get_account_by_management_slug ( management_slug )
if not account or account [ "disabled" ]:
raise HTTPException ( status_code = 404 , detail = "not found" )
hostname = str ( account [ "hostname" ])
try :
service . delete_account ( hostname , "management-link" )
except Exception :
return PlainTextResponse ( "DNS delete failed; hostname was not deleted" , status_code = 500 )
return HTMLResponse (
_page (
"DynDNS deleted" ,
2026-05-20 14:08:15 +02:00
"""
<main>
<section class="hero-band compact">
<div class="container hero-inner">
<p class="eyebrow">Deleted</p>
<h1>Hostname deleted.</h1>
<p class="lead">The update URL no longer works.</p>
<a class="button secondary" href="/">Home</a>
</div>
</section>
</main>
""" ,
2026-05-20 13:48:24 +02:00
)
)
@app.post ( "/request-domain" , response_class = HTMLResponse )
async def request_domain ( request : Request ) -> str :
2026-05-20 18:17:20 +02:00
form = await _request_form ( request , settings )
2026-05-20 13:48:24 +02:00
try :
2026-05-20 17:46:10 +02:00
challenge = await run_in_threadpool (
service . create_domain_challenge ,
_first_form_value ( form , "domain" ),
)
2026-05-20 13:48:24 +02:00
except sqlite3 . IntegrityError as exc :
raise HTTPException ( status_code = 409 , detail = "domain is already verified" ) from exc
2026-05-20 17:46:10 +02:00
return _render_public_home ( service , settings , None , None , challenge )
2026-05-20 13:48:24 +02:00
@app.post ( "/verify-domain" , response_class = HTMLResponse )
async def verify_domain ( request : Request ) -> str :
2026-05-20 18:17:20 +02:00
form = await _request_form ( request , settings )
2026-05-20 13:48:24 +02:00
domain = normalize_domain ( _first_form_value ( form , "domain" ))
claim_secret = _first_form_value ( form , "claim_secret" )
2026-05-20 17:57:42 +02:00
found , challenge = await run_in_threadpool ( service . verify_domain , domain , claim_secret )
2026-05-20 13:48:24 +02:00
if not found :
2026-05-20 17:46:10 +02:00
message = "TXT record not found yet. DNS propagation can take a few minutes."
2026-05-20 17:57:42 +02:00
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 )
2026-05-20 13:48:24 +02:00
@app.post ( "/accounts" , response_class = HTMLResponse )
async def self_service_account ( request : Request ) -> str :
2026-05-20 18:17:20 +02:00
form = await _request_form ( request , settings )
2026-05-20 13:48:24 +02:00
mode = _first_form_value ( form , "mode" )
username = _first_form_value ( form , "username" ) or None
if mode == "managed" :
2026-05-20 17:57:42 +02:00
account = await run_in_threadpool ( service . create_managed_account , username )
2026-05-20 13:48:24 +02:00
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 ,
username ,
)
2026-05-20 17:57:42 +02:00
return _render_public_home ( service , settings , account , None )
2026-05-20 13:48:24 +02:00
2026-05-20 18:17:20 +02:00
@app.get ( "/admin" , response_class = HTMLResponse , include_in_schema = False )
2026-05-20 13:48:24 +02:00
def admin ( _ : None = Depends ( authenticate_admin )) -> str :
2026-05-20 18:17:20 +02:00
return _render_admin_page (
service ,
settings ,
store . list_accounts (),
store . list_domain_challenges (),
store . list_cleanup_runs (),
store . list_update_events ( 25 ),
None ,
None ,
)
2026-05-20 13:48:24 +02:00
2026-05-20 18:17:20 +02:00
@app.post ( "/admin/accounts" , include_in_schema = False )
2026-05-20 13:48:24 +02:00
async def admin_create_account ( request : Request , _ : None = Depends ( authenticate_admin )) -> Response :
2026-05-20 18:17:20 +02:00
form = await _request_form ( request , settings )
2026-05-20 13:48:24 +02:00
require_admin_csrf ( settings , _first_form_value ( form , "csrf" ))
hostname = normalize_hostname ( _first_form_value ( form , "hostname" ), settings )
username = _first_form_value ( form , "username" ) or None
if not hostname :
raise HTTPException ( status_code = 400 , detail = "hostname is required" )
if settings . allowed_hosts and hostname not in settings . allowed_hosts :
raise HTTPException ( status_code = 403 , detail = "hostname is not allowed" )
try :
account = store . create_account ( hostname , username )
except sqlite3 . IntegrityError as exc :
raise HTTPException ( status_code = 409 , detail = "hostname already exists" ) from exc
2026-05-20 18:17:20 +02:00
return HTMLResponse (
_render_admin_page (
service ,
settings ,
store . list_accounts (),
store . list_domain_challenges (),
store . list_cleanup_runs (),
store . list_update_events ( 25 ),
account ,
None ,
)
)
2026-05-20 13:48:24 +02:00
2026-05-20 18:17:20 +02:00
@app.post ( "/admin/accounts/delete" , include_in_schema = False )
2026-05-20 13:48:24 +02:00
async def admin_delete_account ( request : Request , _ : None = Depends ( authenticate_admin )) -> Response :
2026-05-20 18:17:20 +02:00
form = await _request_form ( request , settings )
2026-05-20 13:48:24 +02:00
require_admin_csrf ( settings , _first_form_value ( form , "csrf" ))
hostname = _first_form_value ( form , "hostname" )
if hostname :
try :
service . delete_account ( hostname , "admin" )
except Exception :
return PlainTextResponse ( "DNS delete failed; hostname was not deleted" , status_code = 500 )
return RedirectResponse ( "/admin" , status_code = 303 )
2026-05-20 18:17:20 +02:00
@app.post ( "/admin/accounts/rotate" , include_in_schema = False )
async def admin_rotate_account ( request : Request , _ : None = Depends ( authenticate_admin )) -> Response :
form = await _request_form ( request , settings )
require_admin_csrf ( settings , _first_form_value ( form , "csrf" ))
hostname = normalize_hostname ( _first_form_value ( form , "hostname" ), settings , expand_suffix = False )
action = _first_form_value ( form , "action" )
account = store . get_account_by_hostname ( hostname )
if not account :
raise HTTPException ( status_code = 404 , detail = "hostname not found" )
notice = None
if action == "update" :
rotated = store . rotate_update_slug ( hostname )
notice = _admin_secret_panel ( "Update URL rotated" , [ _copy_row ( "Update-URL:" , service . fritz_update_url ( rotated ))]) if rotated else None
elif action == "management" :
rotated = store . rotate_management_slug ( hostname )
notice = _admin_secret_panel ( "Management link rotated" , [ _copy_row ( "Magic management link:" , service . magic_management_url ( rotated ))]) if rotated else None
elif action == "password" :
rotated = store . rotate_password ( hostname )
if rotated :
notice = _admin_secret_panel (
"Router password rotated" ,
[
_copy_row ( "Domainnamen:" , str ( rotated [ "hostname" ])),
_copy_row ( "Benutzername:" , str ( rotated [ "username" ])),
_copy_row ( "Kennwort:" , str ( rotated [ "password" ])),
],
)
else :
raise HTTPException ( status_code = 400 , detail = "invalid rotation action" )
return HTMLResponse (
_render_admin_page (
service ,
settings ,
store . list_accounts (),
store . list_domain_challenges (),
store . list_cleanup_runs (),
store . list_update_events ( 25 ),
None ,
notice ,
)
)
@app.post ( "/admin/cleanup" , include_in_schema = False )
async def admin_run_cleanup ( request : Request , _ : None = Depends ( authenticate_admin )) -> Response :
form = await _request_form ( request , settings )
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 )
notice = _admin_secret_panel (
"Cleanup completed" ,
[
_copy_row ( "Domain claims removed" , str ( result [ "domain_challenges" ])),
_copy_row ( "Unused hostnames removed" , str ( result [ "unused_accounts" ])),
],
)
return HTMLResponse (
_render_admin_page (
service ,
settings ,
store . list_accounts (),
store . list_domain_challenges (),
store . list_cleanup_runs (),
store . list_update_events ( 25 ),
None ,
notice ,
)
)
@app.get ( "/records" , include_in_schema = False )
2026-05-20 13:48:24 +02:00
def records ( _ : None = Depends ( authenticate_admin )) -> list [ dict [ str , str | None ]]:
return store . list_records ()
2026-05-20 18:17:20 +02:00
@app.get ( "/events" , include_in_schema = False )
2026-05-20 13:48:24 +02:00
def events ( _ : None = Depends ( authenticate_admin )) -> list [ dict [ str , str | None ]]:
return store . list_update_events ()
@app.get ( "/healthz" , response_class = PlainTextResponse )
def healthz () -> str :
return "ok"
@app.get ( "/favicon.ico" , include_in_schema = False )
2026-05-20 14:29:07 +02:00
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" )
2026-05-20 13:48:24 +02:00
@app.get ( "/u/ {update_slug} " , response_class = PlainTextResponse , response_model = None )
def slug_update (
update_slug : str ,
request : Request ,
myip : str | None = Query ( default = None ),
ipaddr : str | None = Query ( default = None ),
myipv6 : str | None = Query ( default = None ),
ip6addr : str | None = Query ( default = None ),
) -> str | PlainTextResponse :
account = store . get_account_by_slug ( update_slug )
if not account or account [ "disabled" ]:
raise HTTPException ( status_code = 404 , detail = "not found" )
return apply_update ( request , str ( account [ "hostname" ]), myip , ipaddr , myipv6 , ip6addr )
@app.get ( "/nic/update" , response_class = PlainTextResponse , response_model = None )
def update (
request : Request ,
credentials : tuple [ str | None , str | None ] = Depends ( authenticate_update ),
hostname : str | None = Query ( default = None ),
domain : str | None = Query ( default = None ),
myip : str | None = Query ( default = None ),
ipaddr : str | None = Query ( default = None ),
myipv6 : str | None = Query ( default = None ),
ip6addr : str | None = Query ( default = None ),
) -> str | PlainTextResponse :
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 :
return PlainTextResponse ( "nohost" , status_code = 403 )
supplied_user , supplied_token = credentials
global_secret_ok = settings . shared_secret and supplied_token and secrets . compare_digest ( supplied_token , settings . shared_secret )
if not global_secret_ok and not store . verify_account ( host , supplied_user , supplied_token ):
return PlainTextResponse ( "badauth" , status_code = 401 )
return apply_update ( request , host , myip , ipaddr , myipv6 , ip6addr )
return app
2026-05-20 18:01:29 +02:00
async def _cleanup_loop ( store : DdnsStore , settings : DdnsSettings ) -> None :
while True :
await asyncio . sleep ( settings . cleanup_interval_seconds )
await run_in_threadpool (
store . cleanup ,
settings . cleanup_challenge_hours ,
settings . cleanup_unused_account_hours ,
)
2026-05-20 13:48:24 +02:00
def _parse_form ( body : bytes ) -> dict [ str , list [ str ]]:
return urllib . parse . parse_qs ( body . decode ( "utf-8" ), keep_blank_values = True )
2026-05-20 18:17:20 +02:00
async def _request_form ( request : Request , settings : DdnsSettings ) -> dict [ str , list [ str ]]:
body = await request . body ()
if len ( body ) > settings . max_request_body_bytes :
raise HTTPException ( status_code = 413 , detail = "request body too large" )
return _parse_form ( body )
2026-05-20 13:48:24 +02:00
def _first_form_value ( form : dict [ str , list [ str ]], key : str ) -> str :
return form . get ( key , [ "" ])[ 0 ] . strip ()
2026-05-20 18:17:20 +02:00
def _is_admin_path ( path : str ) -> bool :
return path == "/admin" or path . startswith ( "/admin/" ) or path in { "/records" , "/events" }
def _is_secret_response ( request : Request ) -> bool :
path = request . url . path
if _is_admin_path ( path ) or path . startswith (( "/m/" , "/d/" )):
return True
if path . startswith (( "/api/v1/hostnames/" , "/api/v1/domains/" )):
return request . method in { "POST" , "DELETE" }
return request . method == "POST" and path in { "/magic" , "/request-domain" , "/verify-domain" , "/accounts" }
2026-05-20 14:08:15 +02:00
def _render_public_home (
service : DdnsService ,
settings : DdnsSettings ,
created_account : dict [ str , str ] | None ,
message : str | None ,
challenge : dict [ str , str | None ] | None = None ,
) -> str :
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 ""
2026-05-20 17:46:10 +02:00
challenge_html = _custom_domain_flow ( service , challenge , message )
2026-05-20 13:48:24 +02:00
return _page (
2026-05-20 14:08:15 +02:00
"router-dyndns" ,
2026-05-20 13:48:24 +02:00
f """
2026-05-20 18:08:54 +02:00
<main id="main">
2026-05-20 14:15:14 +02:00
{ _top_nav () }
2026-05-20 14:08:15 +02:00
<section class="hero-band">
<div class="container hero-inner">
2026-05-20 13:48:24 +02:00
<p class="eyebrow">FRITZ!Box compatible</p>
2026-05-20 18:08:54 +02:00
<h1>DynDNS without signups.</h1>
2026-05-20 14:08:15 +02:00
<p class="lead">Create a secure update endpoint under { suffix } . Provider hostnames are anonymous; custom domains require DNS proof.</p>
<div class="hero-actions">
2026-05-20 18:08:54 +02:00
<a class="button" href="#create">Get a hostname</a>
<a class="button secondary" href="#custom-domain">Verify a domain</a>
2026-05-20 14:08:15 +02:00
</div>
2026-05-20 13:48:24 +02:00
</div>
</section>
{ message_html }
{ created_html }
2026-05-20 14:08:15 +02:00
<section class="section" id="create">
<div class="container tool-grid">
<div>
<p class="eyebrow">Free DynDNS hostname</p>
<h2>Create a router update URL</h2>
<p class="section-copy">Generate a random hostname, paste the update URL into your router, and keep the management link private.</p>
</div>
2026-05-20 18:08:54 +02:00
<form method="post" action="/magic" class="tool-card" aria-label="Create a generated DynDNS hostname">
<label>Router username
<input name="username" placeholder="optional" autocomplete="off" inputmode="text">
<span class="field-hint">Leave empty to generate one automatically.</span>
2026-05-20 14:08:15 +02:00
</label>
2026-05-20 18:08:54 +02:00
<button type="submit">Generate hostname</button>
2026-05-20 14:08:15 +02:00
</form>
</div>
</section>
2026-05-20 17:46:10 +02:00
{ challenge_html }
2026-05-20 14:08:15 +02:00
<footer class="footer">
<div class="container footer-links">
<a href="/docs">API docs</a>
<a href="/redoc">ReDoc</a>
<a href="/admin">Admin</a>
</div>
</footer>
2026-05-20 13:48:24 +02:00
</main>
""" ,
)
2026-05-20 17:57:42 +02:00
def _top_nav ( label : str | None = None , links : str = "" ) -> str :
2026-05-20 14:15:14 +02:00
account_html = ""
if label :
account_html = f '<span class="nav-label"> { html . escape ( label ) } </span>'
2026-05-20 17:57:42 +02:00
if not links :
2026-05-20 17:46:10 +02:00
account_html += '<a class="nav-button" href="/#custom-domain">Domain setup</a>'
2026-05-20 14:15:14 +02:00
return f """
2026-05-20 18:08:54 +02:00
<a class="skip-link" href="#main">Skip to content</a>
<nav class="top-nav" aria-label="Main navigation">
2026-05-20 14:15:14 +02:00
<div class="container nav-inner">
2026-05-20 14:29:07 +02:00
<a class="brand" href="/"><img src="/logo.svg" alt="" width="28" height="28">router-dyndns</a>
2026-05-20 14:15:14 +02:00
<div class="nav-actions">
{ links }
{ account_html }
</div>
</div>
</nav>
"""
2026-05-20 14:08:15 +02:00
def _custom_domain_flow ( service : DdnsService , challenge : dict [ str , str | None ] | None , message : str | None ) -> str :
2026-05-20 13:48:24 +02:00
challenge_html = ""
2026-05-20 14:08:15 +02:00
create_form = ""
2026-05-20 13:48:24 +02:00
if challenge :
domain = html . escape ( str ( challenge [ "domain" ]))
2026-05-20 14:08:15 +02:00
token = str ( challenge [ "token" ])
2026-05-20 13:48:24 +02:00
claim_secret = html . escape ( str ( challenge . get ( "claim_secret" ) or "" ))
2026-05-20 14:08:15 +02:00
verification_name = service . verification_name ( str ( challenge [ "domain" ]))
2026-05-20 17:46:10 +02:00
claim_url = service . domain_claim_url ( challenge )
2026-05-20 18:08:54 +02:00
status = html . escape ( message or "Add this TXT record at your DNS provider, then check when it has propagated." )
2026-05-20 13:48:24 +02:00
challenge_html = f """
2026-05-20 14:08:15 +02:00
<div class="step-card">
<span class="step-number">2</span>
2026-05-20 14:15:14 +02:00
<h3>Add the TXT record</h3>
2026-05-20 13:48:24 +02:00
<p class="note"> { status } </p>
2026-05-20 14:08:15 +02:00
{ _copy_row ( "TXT name" , verification_name ) }
{ _copy_row ( "TXT value" , token ) }
2026-05-20 17:46:10 +02:00
{ _copy_row ( "Private claim link" , claim_url ) }
2026-05-20 18:08:54 +02:00
<form method="post" action="/verify-domain" class="inline-form" aria-label="Check DNS TXT verification">
2026-05-20 13:48:24 +02:00
<input type="hidden" name="domain" value=" { domain } ">
<input type="hidden" name="claim_secret" value=" { claim_secret } ">
2026-05-20 18:08:54 +02:00
<button type="submit">Check DNS record</button>
2026-05-20 13:48:24 +02:00
</form>
</div>
"""
2026-05-20 14:08:15 +02:00
if challenge . get ( "verified_at" ):
create_form = f """
<div class="step-card">
<span class="step-number">3</span>
2026-05-20 14:15:14 +02:00
<h3>Create router hostname</h3>
2026-05-20 18:08:54 +02:00
<form method="post" action="/accounts" class="stack-form" aria-label="Create custom-domain router credentials">
2026-05-20 14:08:15 +02:00
<input type="hidden" name="mode" value="custom">
<input type="hidden" name="claim_secret" value=" { claim_secret } ">
<label>Verified hostname
2026-05-20 18:08:54 +02:00
<input name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
2026-05-20 14:08:15 +02:00
</label>
2026-05-20 18:08:54 +02:00
<label>Router username
<input name="username" placeholder="optional" autocomplete="off">
<span class="field-hint">Leave empty to generate one automatically.</span>
2026-05-20 14:08:15 +02:00
</label>
<button type="submit">Create credentials</button>
</form>
</div>
"""
else :
create_form = """
<div class="step-card muted-card">
<span class="step-number">3</span>
2026-05-20 14:15:14 +02:00
<h3>Create router hostname</h3>
<p class="note">This unlocks after the TXT record is visible in public DNS.</p>
2026-05-20 14:08:15 +02:00
</div>
"""
2026-05-20 13:48:24 +02:00
return f """
2026-05-20 17:46:10 +02:00
<section class="section section-dark" id="custom-domain">
2026-05-20 14:08:15 +02:00
<div class="container">
<div class="section-heading">
<div>
<p class="eyebrow">Custom domain</p>
2026-05-20 18:08:54 +02:00
<h2>Verify DNS ownership</h2>
2026-05-20 14:15:14 +02:00
<p class="section-copy">We only issue router credentials after the DNS record proves you control the domain.</p>
2026-05-20 14:08:15 +02:00
</div>
</div>
<div class="step-grid">
<div class="step-card">
<span class="step-number">1</span>
2026-05-20 14:15:14 +02:00
<h3>Enter domain</h3>
2026-05-20 18:08:54 +02:00
<form method="post" action="/request-domain" class="stack-form" aria-label="Request a custom domain TXT challenge">
2026-05-20 14:15:14 +02:00
<label>Domain
2026-05-20 18:08:54 +02:00
<input name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
2026-05-20 14:08:15 +02:00
</label>
2026-05-20 18:08:54 +02:00
<button type="submit">Create TXT challenge</button>
2026-05-20 14:08:15 +02:00
</form>
</div>
{ challenge_html }
{ create_form }
</div>
</div>
</section>
"""
def _credentials_panel ( service : DdnsService , created_account : dict [ str , str ] | None ) -> str :
if not created_account :
return ""
hostname = created_account [ "hostname" ]
username = created_account [ "username" ]
password = created_account [ "password" ]
update_url = service . fritz_update_url ( created_account )
management_url = service . magic_management_url ( created_account )
return f """
<section class="section success-section">
<div class="container">
2026-05-20 18:08:54 +02:00
<p class="eyebrow">Credentials generated</p>
2026-05-20 14:08:15 +02:00
<h2> { html . escape ( hostname ) } </h2>
<p class="section-copy">Save this now. The password and management link are only shown on this screen.</p>
<div class="copy-list">
{ _copy_row ( "Update-URL:" , update_url ) }
{ _copy_row ( "Domainnamen:" , hostname ) }
{ _copy_row ( "Benutzername:" , username ) }
{ _copy_row ( "Kennwort:" , password ) }
{ _copy_row ( "Magic management link:" , management_url ) }
</div>
</div>
2026-05-20 13:48:24 +02:00
</section>
"""
def _created_account_panel ( service : DdnsService , created_account : dict [ str , str ] | None ) -> str :
2026-05-20 14:08:15 +02:00
return _credentials_panel ( service , created_account )
2026-05-20 18:17:20 +02:00
def _admin_secret_panel ( title : str , rows : list [ str ]) -> str :
return f """
<section class="section success-section">
<div class="container">
<p class="eyebrow">Admin action</p>
<h2> { html . escape ( title ) } </h2>
<p class="section-copy">Copy this now. Secret values are only shown in this response.</p>
<div class="copy-list">
{ "" . join ( rows ) }
</div>
</div>
</section>
"""
2026-05-20 14:08:15 +02:00
def _copy_row ( label : str , value : str ) -> str :
safe_label = html . escape ( label )
safe_value = html . escape ( value )
2026-05-20 13:48:24 +02:00
return f """
2026-05-20 14:08:15 +02:00
<div class="copy-row">
<span> { safe_label } </span>
<code> { safe_value } </code>
2026-05-20 18:08:54 +02:00
<button type="button" class="copy-button" data-copy=" { safe_value } " aria-label="Copy { safe_label } ">Copy</button>
2026-05-20 14:08:15 +02:00
</div>
"""
def _message_band ( message : str | None ) -> str :
if not message :
return ""
return f """
<section class="message-band">
<div class="container"><p> { html . escape ( message ) } </p></div>
2026-05-20 13:48:24 +02:00
</section>
"""
2026-05-20 14:08:15 +02:00
def _render_management_page ( service : DdnsService , settings : DdnsSettings , management_slug : str , account : dict [ str , str | int | None ]) -> str :
account_for_url = {
"hostname" : str ( account [ "hostname" ]),
"username" : str ( account [ "username" ]),
"password" : "" ,
"update_slug" : str ( account [ "update_slug" ]),
"management_slug" : management_slug ,
}
update_url = service . fritz_update_url ( account_for_url )
hostname = html . escape ( str ( account [ "hostname" ]))
username = html . escape ( str ( account [ "username" ]))
ipv4 = html . escape ( str ( account . get ( "ipv4" ) or "-" ))
ipv6 = html . escape ( str ( account . get ( "ipv6" ) or "-" ))
updated = html . escape ( str ( account . get ( "updated_at" ) or "Never" ))
return _page (
"DynDNS management" ,
f """
2026-05-20 18:08:54 +02:00
<main id="main">
2026-05-20 14:15:14 +02:00
{ _top_nav () }
2026-05-20 14:08:15 +02:00
<section class="hero-band compact">
<div class="container hero-inner">
<p class="eyebrow">Magic management</p>
<h1> { hostname } </h1>
<p class="lead">Anyone with this link can manage or delete this hostname. Keep it private.</p>
2026-05-20 18:08:54 +02:00
<a class="button secondary" href="/">Back home</a>
2026-05-20 14:08:15 +02:00
</div>
</section>
<section class="section">
<div class="container tool-grid">
<div>
<p class="eyebrow">Router settings</p>
<h2>FRITZ!Box fields</h2>
2026-05-20 18:08:54 +02:00
<p class="section-copy">Use these values in the FRITZ!Box custom DynDNS provider fields.</p>
2026-05-20 14:08:15 +02:00
</div>
<div class="copy-list">
{ _copy_row ( "Update-URL:" , update_url ) }
{ _copy_row ( "Domainnamen:" , hostname ) }
{ _copy_row ( "Benutzername:" , username ) }
{ _copy_row ( "Kennwort:" , "unchanged; only shown when generated" ) }
</div>
</div>
</section>
<section class="section">
<div class="container split-row">
<div>
<p class="eyebrow">Current address</p>
<h2>Status</h2>
</div>
<table class="status-table">
<tbody>
<tr><th>IPv4</th><td> { ipv4 } </td></tr>
<tr><th>IPv6</th><td> { ipv6 } </td></tr>
<tr><th>Updated</th><td> { updated } </td></tr>
</tbody>
</table>
</div>
</section>
<section class="section danger-section">
<div class="container split-row">
<div>
<p class="eyebrow">Danger zone</p>
<h2>Delete hostname</h2>
2026-05-20 18:08:54 +02:00
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
2026-05-20 14:08:15 +02:00
</div>
<form method="post" action="/m/ { html . escape ( management_slug ) } /delete">
<button class="danger-button" type="submit">Delete hostname</button>
</form>
</div>
</section>
</main>
""" ,
)
2026-05-20 13:48:24 +02:00
def _render_admin_page (
service : DdnsService ,
settings : DdnsSettings ,
accounts : list [ dict [ str , str | int | None ]],
2026-05-20 18:17:20 +02:00
domain_challenges : list [ dict [ str , str | None ]],
cleanup_runs : list [ dict [ str , str | int ]],
update_events : list [ dict [ str , str | None ]],
2026-05-20 13:48:24 +02:00
created_account : dict [ str , str ] | None ,
2026-05-20 18:17:20 +02:00
notice_html : str | None ,
2026-05-20 13:48:24 +02:00
) -> str :
csrf = html . escape ( admin_csrf_token ( settings ))
2026-05-20 17:57:42 +02:00
rows = " \n " . join ( _account_row ( account , csrf = csrf ) for account in accounts ) or """
2026-05-20 18:17:20 +02:00
<tr><td colspan="7" class="empty">No hostnames yet.</td></tr>
"""
domain_rows = " \n " . join ( _domain_claim_row ( challenge ) for challenge in domain_challenges ) or """
<tr><td colspan="4" class="empty">No domain claims yet.</td></tr>
"""
cleanup_rows = " \n " . join ( _cleanup_row ( run ) for run in cleanup_runs ) or """
<tr><td colspan="3" class="empty">No cleanup runs yet.</td></tr>
"""
event_rows = " \n " . join ( _event_row ( event ) for event in update_events ) or """
<tr><td colspan="6" class="empty">No update events yet.</td></tr>
2026-05-20 13:48:24 +02:00
"""
suffix_help = (
f "Short names are expanded under { html . escape ( settings . hostname_suffix ) } ."
if settings . hostname_suffix
else "Use a full hostname, for example home.example.net."
)
return _page (
"DynDNS Admin" ,
f """
2026-05-20 18:08:54 +02:00
<main id="main">
2026-05-20 17:57:42 +02:00
{ _top_nav ( "Operator console" , '<a href="/records">Records</a><a href="/events">Events</a>' ) }
2026-05-20 14:08:15 +02:00
<section class="section admin-heading">
<div class="container">
2026-05-20 13:48:24 +02:00
<p class="eyebrow">DynDNS aktiv</p>
2026-05-20 18:08:54 +02:00
<h1>Router credentials</h1>
2026-05-20 14:08:15 +02:00
<p class="lead">Create, inspect, and retire router update credentials.</p>
2026-05-20 13:48:24 +02:00
</div>
</section>
{ _created_account_panel ( service , created_account ) }
2026-05-20 18:17:20 +02:00
{ notice_html or "" }
<section class="section">
<div class="container tool-grid">
<div>
<p class="eyebrow">Service health</p>
<h2>Scheduled cleanup</h2>
<p class="section-copy">Cleanup runs every { html . escape ( str ( settings . cleanup_interval_seconds )) } seconds. It removes unverified domain claims after { html . escape ( str ( settings . cleanup_challenge_hours )) } hours and never-used generated hostnames after { html . escape ( str ( settings . cleanup_unused_account_hours )) } hours.</p>
</div>
<form method="post" action="/admin/cleanup" class="tool-card" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value=" { csrf } ">
<button type="submit">Run cleanup now</button>
</form>
</div>
</section>
2026-05-20 13:48:24 +02:00
2026-05-20 14:08:15 +02:00
<section class="section">
<div class="container tool-grid">
<div>
2026-05-20 18:08:54 +02:00
<p class="eyebrow">Generate credentials</p>
2026-05-20 14:08:15 +02:00
<h2>Router credentials</h2>
2026-05-20 18:08:54 +02:00
<p class="section-copy">Generated credentials produce the FRITZ!Box fields: Update-URL, Domainnamen, Benutzername, Kennwort.</p>
2026-05-20 14:08:15 +02:00
<p class="note"> { suffix_help } </p>
</div>
2026-05-20 18:08:54 +02:00
<form method="post" action="/admin/accounts" class="tool-card" aria-label="Create router credentials">
2026-05-20 14:08:15 +02:00
<input type="hidden" name="csrf" value=" { csrf } ">
<label>Domainnamen
2026-05-20 18:08:54 +02:00
<input name="hostname" placeholder="home.example.net" autocomplete="off" autocapitalize="none" spellcheck="false" required>
2026-05-20 14:08:15 +02:00
</label>
<label>Benutzername
2026-05-20 18:08:54 +02:00
<input name="username" placeholder="auto-generated" autocomplete="off">
<span class="field-hint">Leave empty to generate one from the hostname.</span>
2026-05-20 14:08:15 +02:00
</label>
2026-05-20 18:08:54 +02:00
<button type="submit">Generate credentials</button>
2026-05-20 14:08:15 +02:00
</form>
2026-05-20 13:48:24 +02:00
</div>
</section>
2026-05-20 14:08:15 +02:00
<section class="section">
<div class="container">
<div class="section-heading">
<div>
2026-05-20 18:08:54 +02:00
<p class="eyebrow">Hostnames</p>
2026-05-20 14:08:15 +02:00
<h2>Active credentials</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
2026-05-20 18:17:20 +02:00
<tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th>Links</th><th></th></tr>
2026-05-20 14:08:15 +02:00
</thead>
<tbody> { rows } </tbody>
</table>
</div>
2026-05-20 13:48:24 +02:00
</div>
</section>
2026-05-20 18:17:20 +02:00
<section class="section">
<div class="container">
<div class="section-heading">
<div>
<p class="eyebrow">Domain claims</p>
<h2>Verification lifecycle</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Domain</th><th>Status</th><th>Created</th><th>Verified</th></tr></thead>
<tbody> { domain_rows } </tbody>
</table>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="section-heading">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Recent runs</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>Domain claims removed</th><th>Unused hostnames removed</th></tr></thead>
<tbody> { cleanup_rows } </tbody>
</table>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="section-heading">
<div>
<p class="eyebrow">Updates</p>
<h2>Recent events</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>Domain</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Detail</th></tr></thead>
<tbody> { event_rows } </tbody>
</table>
</div>
</div>
</section>
2026-05-20 13:48:24 +02:00
</main>
""" ,
)
2026-05-20 17:57:42 +02:00
def _account_row ( account : dict [ str , str | int | None ], csrf : str = "" ) -> str :
2026-05-20 13:48:24 +02:00
hostname = html . escape ( str ( account [ "hostname" ]))
username = html . escape ( str ( account [ "username" ]))
ipv4 = html . escape ( str ( account . get ( "ipv4" ) or "-" ))
ipv6 = html . escape ( str ( account . get ( "ipv6" ) or "-" ))
updated = html . escape ( str ( account . get ( "updated_at" ) or "Never" ))
return f """
<tr>
2026-05-20 17:57:42 +02:00
<td> { hostname } </td><td> { username } </td><td> { ipv4 } </td><td> { ipv6 } </td><td> { updated } </td>
2026-05-20 18:17:20 +02:00
<td>
<form method="post" action="/admin/accounts/rotate" class="table-actions">
<input type="hidden" name="csrf" value=" { html . escape ( csrf ) } ">
<input type="hidden" name="hostname" value=" { hostname } ">
<button name="action" value="update" class="small-button" title="Rotate update URL" aria-label="Rotate update URL for { hostname } ">Update URL</button>
<button name="action" value="management" class="small-button" title="Rotate management link" aria-label="Rotate management link for { hostname } ">Manage link</button>
<button name="action" value="password" class="small-button" title="Rotate router password" aria-label="Rotate router password for { hostname } ">Password</button>
</form>
</td>
2026-05-20 13:48:24 +02:00
<td>
<form method="post" action="/admin/accounts/delete">
<input type="hidden" name="csrf" value=" { html . escape ( csrf ) } ">
<input type="hidden" name="hostname" value=" { hostname } ">
2026-05-20 18:08:54 +02:00
<button class="icon-button" title="Delete hostname" aria-label="Delete { hostname } ">x</button>
2026-05-20 13:48:24 +02:00
</form>
</td>
</tr>
"""
2026-05-20 18:17:20 +02:00
def _domain_claim_row ( challenge : dict [ str , str | None ]) -> str :
domain = html . escape ( str ( challenge [ "domain" ]))
created = html . escape ( str ( challenge . get ( "created_at" ) or "-" ))
verified = html . escape ( str ( challenge . get ( "verified_at" ) or "-" ))
status = "Verified" if challenge . get ( "verified_at" ) else "Pending"
return f "<tr><td> { domain } </td><td> { status } </td><td> { created } </td><td> { verified } </td></tr>"
def _cleanup_row ( run : dict [ str , str | int ]) -> str :
created = html . escape ( str ( run . get ( "created_at" ) or "-" ))
domains = html . escape ( str ( run . get ( "domain_challenges_deleted" ) or 0 ))
accounts = html . escape ( str ( run . get ( "unused_accounts_deleted" ) or 0 ))
return f "<tr><td> { created } </td><td> { domains } </td><td> { accounts } </td></tr>"
def _event_row ( event : dict [ str , str | None ]) -> str :
created = html . escape ( str ( event . get ( "created_at" ) or "-" ))
hostname = html . escape ( str ( event . get ( "hostname" ) or "-" ))
status = html . escape ( str ( event . get ( "status" ) or "-" ))
ipv4 = html . escape ( str ( event . get ( "ipv4" ) or "-" ))
ipv6 = html . escape ( str ( event . get ( "ipv6" ) or "-" ))
detail = html . escape ( str ( event . get ( "detail" ) or "-" ))
return f "<tr><td> { created } </td><td> { hostname } </td><td> { status } </td><td> { ipv4 } </td><td> { ipv6 } </td><td> { detail } </td></tr>"
2026-05-20 13:48:24 +02:00
def _page ( title : str , body : str ) -> str :
return f """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title> { html . escape ( title ) } </title>
2026-05-20 14:29:07 +02:00
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/logo.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/logo.png">
2026-05-20 13:48:24 +02:00
<style>
:root {{
color-scheme: light;
2026-05-20 14:08:15 +02:00
--bg: #f5f5f7;
--surface: #ffffff;
--ink: #1d1d1f;
--muted: #6e6e73;
--line: #d2d2d7;
--soft-line: rgba(0, 0, 0, 0.08);
--blue: #0071e3;
--blue-hover: #0077ed;
--dark: #161617;
--dark-2: #1d1d1f;
--danger: #b42318;
2026-05-20 13:48:24 +02:00
}}
* {{ box-sizing: border-box; }}
2026-05-20 14:08:15 +02:00
html {{ scroll-behavior: smooth; }}
2026-05-20 13:48:24 +02:00
body {{
margin: 0;
min-height: 100vh;
background: var(--bg);
2026-05-20 14:08:15 +02:00
color: var(--ink);
font: 400 16px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
2026-05-20 13:48:24 +02:00
}}
2026-05-20 14:08:15 +02:00
.container {{ width: min(1040px, calc(100% - 40px)); margin: 0 auto; }}
2026-05-20 18:08:54 +02:00
.skip-link {{ position: fixed; left: 16px; top: 10px; z-index: 40; transform: translateY(-140%); padding: 10px 14px; border-radius: 999px; background: var(--ink); color: #fff; text-decoration: none; font-size: 14px; transition: transform .16s ease; }}
.skip-link:focus {{ transform: translateY(0); }}
2026-05-20 14:15:14 +02:00
.top-nav {{ position: sticky; top: 0; z-index: 20; min-height: 52px; display: flex; align-items: center; background: rgba(245, 245, 247, 0.86); border-bottom: 1px solid rgba(0, 0, 0, 0.08); backdrop-filter: saturate(180%) blur(20px); }}
.nav-inner {{ min-height: 52px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }}
2026-05-20 14:29:07 +02:00
.brand {{ display: inline-flex; align-items: center; gap: 9px; color: var(--ink); font-size: 15px; font-weight: 650; letter-spacing: -0.01em; text-decoration: none; white-space: nowrap; }}
.brand img {{ width: 28px; height: 28px; display: block; }}
2026-05-20 14:15:14 +02:00
.nav-actions {{ min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 14px; }}
.nav-actions a:not(.nav-button) {{ color: var(--muted); font-size: 13px; text-decoration: none; white-space: nowrap; }}
.nav-actions a:not(.nav-button):hover {{ color: var(--ink); }}
.nav-label {{ min-width: 0; max-width: 38vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 13px; }}
.nav-button {{ min-height: 34px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 999px; background: #e8e8ed; color: var(--ink); font: inherit; font-size: 13px; font-weight: 500; text-decoration: none; cursor: pointer; }}
.nav-button:hover {{ background: #dedee3; }}
2026-05-20 18:08:54 +02:00
.hero-band {{ min-height: calc(70vh - 52px); display: grid; align-items: center; background: var(--bg); text-align: center; padding: 80px 0 72px; }}
.hero-band.compact {{ min-height: 360px; }}
2026-05-20 14:08:15 +02:00
.hero-inner {{ display: grid; justify-items: center; gap: 18px; }}
2026-05-20 18:08:54 +02:00
.section {{ padding: 68px 0; background: var(--surface); }}
2026-05-20 14:08:15 +02:00
.section + .section {{ border-top: 1px solid rgba(0, 0, 0, 0.04); }}
.section-dark {{ background: var(--dark); color: #f5f5f7; border-top: 0; }}
.success-section {{ background: #f5f5f7; }}
.danger-section {{ background: #fff; border-top: 1px solid var(--soft-line); }}
.message-band {{ background: #fff8e6; color: var(--ink); padding: 18px 0; border-block: 1px solid #f0cc74; }}
2026-05-20 14:15:14 +02:00
.split-row, .section-heading {{ display: flex; align-items: center; justify-content: space-between; gap: 24px; }}
2026-05-20 14:08:15 +02:00
.page-heading, .admin-heading .container {{ max-width: 760px; }}
2026-05-20 13:48:24 +02:00
.button-row {{ display: flex; gap: 10px; flex-wrap: wrap; }}
2026-05-20 14:08:15 +02:00
h1, h2, h3, p {{ margin: 0; }}
h1 {{ max-width: 760px; font-size: 48px; line-height: 1.08; font-weight: 650; letter-spacing: -0.02em; }}
h2 {{ font-size: 28px; line-height: 1.16; font-weight: 600; letter-spacing: -0.015em; }}
h3 {{ font-size: 18px; line-height: 1.25; font-weight: 600; }}
.eyebrow {{ color: var(--blue); font-size: 13px; font-weight: 600; letter-spacing: 0; }}
.lead {{ max-width: 660px; color: var(--muted); font-size: 19px; line-height: 1.47; letter-spacing: -0.01em; }}
.section-dark .lead, .section-dark .section-copy, .section-dark .note {{ color: #cccccc; }}
.section-copy, .intro {{ margin-top: 12px; max-width: 560px; color: var(--muted); font-size: 15px; line-height: 1.5; }}
.hero-actions {{ display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; margin-top: 8px; }}
.tool-grid {{ display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 440px); gap: 40px; align-items: start; }}
.tool-card, .step-card, .secondary-card {{ display: grid; gap: 16px; padding: 24px; background: var(--surface); border: 1px solid var(--soft-line); border-radius: 8px; }}
.secondary-card {{ background: #fafafc; }}
.section-dark .step-card {{ background: #242426; border-color: rgba(255, 255, 255, 0.12); }}
.section-dark .muted-card {{ opacity: .66; }}
.step-grid {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-top: 28px; }}
.stack-form {{ display: grid; gap: 16px; }}
2026-05-20 13:48:24 +02:00
.inline-form {{ margin-top: 14px; }}
2026-05-20 14:08:15 +02:00
label {{ display: grid; gap: 8px; color: var(--muted); font-size: 13px; font-weight: 500; }}
input {{ width: 100%; min-height: 44px; border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; color: var(--ink); font: inherit; background: #fff; outline: none; }}
input:focus {{ border-color: var(--blue); box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.16); }}
2026-05-20 18:08:54 +02:00
.field-hint {{ color: var(--muted); font-size: 12px; font-weight: 400; line-height: 1.35; }}
2026-05-20 14:08:15 +02:00
button, .button {{ min-height: 44px; border: 0; border-radius: 999px; padding: 0 20px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; background: var(--blue); color: #fff; font: inherit; font-size: 15px; font-weight: 500; text-decoration: none; cursor: pointer; transition: background .16s ease, transform .16s ease; }}
button:hover, .button:hover {{ background: var(--blue-hover); }}
button:active, .button:active {{ transform: scale(.97); }}
2026-05-20 18:08:54 +02:00
button:focus-visible, .button:focus-visible, a:focus-visible, input:focus-visible {{ outline: 2px solid var(--blue); outline-offset: 3px; }}
2026-05-20 14:08:15 +02:00
.secondary {{ background: #e8e8ed; color: var(--ink); }}
.secondary:hover {{ background: #dedee3; }}
.secondary-on-dark {{ background: transparent; color: #f5f5f7; border: 1px solid rgba(255, 255, 255, 0.36); }}
.secondary-on-dark:hover {{ background: rgba(255, 255, 255, 0.12); }}
.danger-button {{ background: transparent; color: var(--danger); border: 1px solid rgba(180, 35, 24, 0.28); }}
.danger-button:hover {{ background: #fff1f0; }}
.note {{ margin-top: 10px; color: var(--muted); font-size: 13px; line-height: 1.45; }}
.copy-list {{ display: grid; gap: 10px; }}
.copy-row {{ display: grid; grid-template-columns: 150px minmax(0, 1fr) auto; gap: 12px; align-items: center; min-height: 48px; padding: 10px 10px 10px 14px; background: #fff; border: 1px solid var(--soft-line); border-radius: 8px; }}
.copy-row span {{ color: var(--muted); font-size: 13px; }}
2026-05-20 18:08:54 +02:00
.copy-row code {{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }}
2026-05-20 14:08:15 +02:00
.copy-button {{ min-height: 32px; padding: 0 12px; border-radius: 999px; background: #f5f5f7; color: var(--blue); font-size: 13px; }}
.copy-button:hover {{ background: #e8e8ed; }}
2026-05-20 18:08:54 +02:00
.section-dark .copy-row {{ background: #1d1d1f; border-color: rgba(255, 255, 255, 0.14); }}
.section-dark .copy-row code {{ color: #f5f5f7; }}
2026-05-20 14:08:15 +02:00
.step-number {{ width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: var(--blue); color: white; border-radius: 50%; font-size: 13px; }}
2026-05-20 18:08:54 +02:00
.table-wrap {{ overflow-x: auto; border: 1px solid var(--soft-line); border-radius: 8px; }}
2026-05-20 14:08:15 +02:00
table {{ width: 100%; border-collapse: collapse; font-size: 14px; font-variant-numeric: tabular-nums; }}
th, td {{ border-bottom: 1px solid var(--soft-line); padding: 14px 10px; text-align: left; white-space: nowrap; }}
2026-05-20 18:08:54 +02:00
tr:last-child th, tr:last-child td {{ border-bottom: 0; }}
2026-05-20 14:08:15 +02:00
th {{ color: var(--muted); font-size: 12px; font-weight: 600; }}
2026-05-20 13:48:24 +02:00
.empty {{ color: var(--muted); text-align: center; padding: 28px; }}
2026-05-20 14:08:15 +02:00
.icon-button {{ width: 32px; min-height: 32px; padding: 0; background: #f5f5f7; color: var(--danger); }}
.icon-button:hover {{ background: #fff1f0; }}
2026-05-20 18:17:20 +02:00
.table-actions {{ display: flex; gap: 6px; flex-wrap: wrap; }}
.small-button {{ min-height: 32px; padding: 0 11px; background: #f5f5f7; color: var(--blue); font-size: 12px; }}
.small-button:hover {{ background: #e8e8ed; }}
2026-05-20 18:08:54 +02:00
.footer {{ padding: 28px 0; background: var(--bg); border-top: 1px solid var(--soft-line); }}
.footer-links {{ display: flex; justify-content: center; gap: 18px; flex-wrap: wrap; }}
.footer-links a {{ color: var(--muted); font-size: 13px; text-decoration: none; }}
.footer-links a:hover {{ color: var(--ink); }}
2026-05-20 14:08:15 +02:00
@media (max-width: 860px) {{
2026-05-20 18:08:54 +02:00
.tool-grid, .step-grid {{ grid-template-columns: 1fr; }}
2026-05-20 14:15:14 +02:00
.split-row, .section-heading {{ align-items: flex-start; flex-direction: column; }}
2026-05-20 14:08:15 +02:00
.section, .hero-band {{ padding: 52px 0; }}
2026-05-20 18:08:54 +02:00
.hero-band {{ min-height: auto; }}
2026-05-20 14:08:15 +02:00
h1 {{ font-size: 36px; }}
.lead {{ font-size: 17px; }}
}}
@media (max-width: 560px) {{
.container {{ width: min(100% - 28px, 1040px); }}
2026-05-20 14:15:14 +02:00
.nav-inner {{ gap: 10px; }}
.nav-actions {{ gap: 8px; }}
.nav-label {{ display: none; }}
2026-05-20 18:08:54 +02:00
.brand {{ font-size: 14px; }}
.brand img {{ width: 24px; height: 24px; }}
.hero-actions, .button-row {{ width: 100%; flex-direction: column; }}
.hero-actions .button, .tool-card button, .step-card button, .danger-button {{ width: 100%; }}
.tool-card, .step-card, .secondary-card {{ padding: 18px; }}
2026-05-20 14:08:15 +02:00
.copy-row {{ grid-template-columns: 1fr; }}
2026-05-20 18:08:54 +02:00
.copy-row code {{ white-space: normal; overflow-wrap: anywhere; }}
.copy-button {{ width: 100%; }}
.table-wrap {{ margin-inline: -4px; }}
th, td {{ padding: 12px 9px; }}
2026-05-20 14:08:15 +02:00
h1 {{ font-size: 31px; }}
2026-05-20 18:08:54 +02:00
h2 {{ font-size: 25px; }}
2026-05-20 13:48:24 +02:00
}}
</style>
2026-05-20 14:08:15 +02:00
<script>
document.addEventListener("click", async (event) => {{
const button = event.target.closest("[data-copy]");
if (!button) return;
try {{
await navigator.clipboard.writeText(button.dataset.copy || "");
const old = button.textContent;
button.textContent = "Copied";
setTimeout(() => {{ button.textContent = old; }} , 1200);
}} catch (_) {{}}
}} );
</script>
2026-05-20 13:48:24 +02:00
</head>
<body> { body } </body>
</html>
"""