Simplify DDNS flow and polish UI

This commit is contained in:
StasonJatham committed 2026-05-21 14:18:39 +02:00
1 parent 5beee0e5c6
commit 5a90bd9d87
42 files changed
+587 -1418

No files matched your search

-6
View File
@@ -39,14 +39,12 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
service,
settings,
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
store.list_domain_challenges(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("domain_challenges", 0)),
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
store.list_scheduled_tasks(),
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
store.get_service_metrics(),
{
"accounts": store.count_accounts(),
"domain_challenges": store.count_domain_challenges(),
"cleanup_runs": store.count_cleanup_runs(),
"update_events": store.count_update_events(),
},
@@ -62,7 +60,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
service: ServiceDep,
_: None = Depends(authenticate_admin),
accounts_offset: int = Query(0, ge=0),
domain_claims_offset: int = Query(0, ge=0),
cleanup_runs_offset: int = Query(0, ge=0),
update_events_offset: int = Query(0, ge=0),
) -> str:
@@ -72,7 +69,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
store,
{
"accounts": accounts_offset,
"domain_challenges": domain_claims_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
},
@@ -184,14 +180,12 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
require_admin_csrf(settings, first_form_value(form, "csrf"))
result = await run_in_threadpool(
store.cleanup,
settings.cleanup_challenge_hours,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
)
notice = _admin_secret_panel(
"Cleanup done",
[
_copy_row("Domain claims removed", str(result["domain_challenges"])),
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
_copy_row("Old update events removed", str(result["update_events"])),
],
+1 -73
View File
@@ -1,24 +1,17 @@
from __future__ import annotations
import sqlite3
from fastapi import APIRouter, HTTPException, Request, Response, status
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_service import BadIpError, normalize_domain
from ..ddns_service import BadIpError
from ..dependencies import ServiceDep, StoreDep
from ..schemas import (
CustomHostnameRequest,
DomainChallengeRequest,
DomainChallengeResponse,
ErrorResponse,
HostnameCredentials,
MagicHostnameRequest,
ManagedRecord,
RouterUpdateResponse,
VerifyDomainRequest,
VerifyDomainResponse,
_credentials_response,
_managed_record,
)
@@ -75,71 +68,6 @@ async def delete_managed_hostname(management_slug: ManagementSlugPath, store: St
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/domains/challenges",
response_model=DomainChallengeResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a DNS TXT domain verification challenge",
tags=["domains"],
responses={400: {"model": ErrorResponse}},
)
async def create_domain_challenge(payload: DomainChallengeRequest, service: ServiceDep) -> DomainChallengeResponse:
domain = normalize_domain(payload.domain)
try:
challenge = await run_in_threadpool(service.create_domain_challenge, domain)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
return DomainChallengeResponse(
domain=domain,
txt_name=service.verification_name(domain),
txt_value=challenge["token"],
claim_secret=challenge["claim_secret"],
)
@router.post(
"/domains/verify",
response_model=VerifyDomainResponse,
summary="Verify a domain TXT challenge",
tags=["domains"],
responses={404: {"model": ErrorResponse}},
)
async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> VerifyDomainResponse:
domain = normalize_domain(payload.domain)
found, _challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret)
if not found:
return VerifyDomainResponse(
domain=domain,
verified=False,
message="TXT record not found yet",
claim_secret=payload.claim_secret,
)
return VerifyDomainResponse(
domain=domain,
verified=True,
message="Domain verified",
claim_secret=payload.claim_secret,
)
@router.post(
"/hostnames/custom",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Create a hostname for a verified domain",
tags=["hostnames"],
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def create_custom_hostname(payload: CustomHostnameRequest, service: ServiceDep) -> HostnameCredentials:
account = await run_in_threadpool(
service.create_custom_account,
payload.hostname,
payload.claim_secret,
payload.username,
)
return _credentials_response(service, account)
@router.get(
"/updates/{update_slug}",
response_model=RouterUpdateResponse,
+23 -3
View File
@@ -60,6 +60,10 @@ THEME_JS = """
CLIPBOARD_JS = """
document.addEventListener("click", async (event) => {
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
if (!menu.contains(event.target)) menu.removeAttribute("open");
});
const toggle = event.target.closest("[data-secret-toggle]");
if (toggle) {
const group = toggle.closest(".copy-field");
@@ -84,13 +88,29 @@ document.addEventListener("click", async (event) => {
if (!button) return;
try {
await navigator.clipboard.writeText(button.dataset.copy || "");
const old = button.textContent;
button.textContent = "Copied";
const label = button.querySelector("[data-copy-label]");
const oldLabel = label ? label.textContent : "";
button.classList.add("is-copied");
if (label) label.textContent = "Copied";
const menu = button.closest("details.copy-menu");
if (menu) {
setTimeout(() => {
menu.removeAttribute("open");
}, 250);
}
setTimeout(() => {
button.textContent = old;
button.classList.remove("is-copied");
if (label) label.textContent = oldLabel;
}, 1200);
} catch (_) {}
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
menu.removeAttribute("open");
});
});
""".strip()
+1 -2
View File
@@ -18,7 +18,7 @@ from ..web.forms import first_form_value, request_form
from ..web.html_utils import trusted
from ..web.templates import _page
from ..web.templates_management import _render_management_page, _render_management_unlock_page
from . import management_domains, management_passwords
from . import management_passwords
from .management_shared import (
get_active_management_account,
get_active_management_account_async,
@@ -30,7 +30,6 @@ from .path_params import ManagementSlugPath
router = APIRouter()
router.include_router(management_passwords.router)
router.include_router(management_domains.router)
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
-154
View File
@@ -1,154 +0,0 @@
from __future__ import annotations
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..ddns_service import normalize_domain
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import (
DASHBOARD_CUSTOM_HOSTNAME_CREATED,
DASHBOARD_DOMAIN_CLAIM_CREATED,
DASHBOARD_DOMAIN_CLAIM_PENDING,
DASHBOARD_DOMAIN_CLAIM_VERIFIED,
)
from ..web.forms import first_form_value, request_form
from ..web.templates_public import _render_public_home
from .management_shared import (
get_active_management_account_async,
has_management_access,
locked_management_response,
render_management_page_async,
)
from .path_params import ClaimSecretPath, ManagementSlugPath
router = APIRouter()
@router.get("/d/{claim_secret}", response_class=HTMLResponse, include_in_schema=False)
async def manage_domain_claim(claim_secret: ClaimSecretPath, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
challenge = await run_in_threadpool(store.get_domain_challenge_by_secret, claim_secret)
if not challenge:
raise HTTPException(status_code=404, detail="not found")
return await run_in_threadpool(_render_public_home, service, settings, None, None, challenge)
@router.post("/m/{management_slug}/domains/request", response_class=HTMLResponse, include_in_schema=False)
async def request_dashboard_domain(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
try:
challenge = await run_in_threadpool(
service.create_domain_challenge,
first_form_value(form, "domain"),
str(account["hostname"]),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
await run_in_threadpool(
store.log_update_event,
str(account["hostname"]),
None,
None,
DASHBOARD_DOMAIN_CLAIM_CREATED,
"domain claim created",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Claim created. Add the TXT record, then verify it.",
challenge=challenge,
)
@router.post("/m/{management_slug}/domains/verify", response_class=HTMLResponse, include_in_schema=False)
async def verify_dashboard_domain(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
domain = normalize_domain(first_form_value(form, "domain"))
claim_secret = first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, str(account["hostname"]))
message = "Domain verified. You can create a hostname now." if found else "TXT record not found yet. Try again in a few minutes."
await run_in_threadpool(
store.log_update_event,
str(account["hostname"]),
None,
None,
DASHBOARD_DOMAIN_CLAIM_VERIFIED if found else DASHBOARD_DOMAIN_CLAIM_PENDING,
"domain claim verified" if found else "domain claim TXT not found",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message=message,
challenge=challenge,
)
@router.post("/m/{management_slug}/domains/hostnames", response_class=HTMLResponse, include_in_schema=False)
async def create_dashboard_custom_hostname(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
created = await run_in_threadpool(
service.create_custom_account,
first_form_value(form, "hostname"),
first_form_value(form, "claim_secret"),
None,
str(account["hostname"]),
)
await run_in_threadpool(
store.log_update_event,
str(account["hostname"]),
None,
None,
DASHBOARD_CUSTOM_HOSTNAME_CREATED,
"custom hostname created",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Hostname created. Copy these router settings now.",
created_account=created,
)
@@ -78,7 +78,6 @@ async def render_management_page_async(
account: ManagementAccount,
csrf_token: str,
message: str | None = None,
challenge: dict[str, str | None] | None = None,
created_account: dict[str, str] | None = None,
) -> str:
return await run_in_threadpool(
@@ -89,7 +88,6 @@ async def render_management_page_async(
account,
csrf_token,
message,
challenge,
created_account,
)
-9
View File
@@ -26,12 +26,3 @@ ManagementSlugPath = Annotated[
description="Private dashboard URL value.",
),
]
ClaimSecretPath = Annotated[
str,
Path(
min_length=TOKEN_MIN_LENGTH,
max_length=TOKEN_MAX_LENGTH,
pattern=TOKEN_URLSAFE_PATTERN,
description="Private domain claim value.",
),
]
+5 -39
View File
@@ -1,13 +1,10 @@
from __future__ import annotations
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip
from ..ddns_service import normalize_domain
from ..dependencies import ServiceDep, SettingsDep
from ..web.forms import first_form_value, request_form
from ..web.templates_public import _render_privacy_page, _render_public_home
@@ -32,45 +29,15 @@ async def magic_account(request: Request, settings: SettingsDep, service: Servic
return _render_public_home(service, settings, account, None)
@router.post("/request-domain", response_class=HTMLResponse, include_in_schema=False)
async def request_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
form = await request_form(request, settings)
try:
challenge = await run_in_threadpool(
service.create_domain_challenge,
first_form_value(form, "domain"),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
return _render_public_home(service, settings, None, None, challenge)
@router.post("/verify-domain", response_class=HTMLResponse, include_in_schema=False)
async def verify_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
form = await request_form(request, settings)
domain = normalize_domain(first_form_value(form, "domain"))
claim_secret = first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
if not found:
message = "TXT record not found yet. Try again in a few minutes."
return _render_public_home(service, settings, None, message, challenge)
return _render_public_home(service, settings, None, "Domain verified. You can create a hostname now.", challenge)
@router.post("/accounts", response_class=HTMLResponse, include_in_schema=False)
async def self_service_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
form = await request_form(request, settings)
mode = first_form_value(form, "mode")
if mode == "managed":
account = await run_in_threadpool(service.create_managed_account, None)
else:
claim_secret = first_form_value(form, "claim_secret")
account = await run_in_threadpool(
service.create_custom_account,
first_form_value(form, "hostname"),
claim_secret,
None,
)
if mode and mode != "managed":
raise HTTPException(status_code=400, detail="custom hostnames are not supported; create a KarlDNS hostname and use a CNAME")
if first_form_value(form, "hostname") or first_form_value(form, "claim_secret"):
raise HTTPException(status_code=400, detail="custom hostnames are not supported; create a KarlDNS hostname and use a CNAME")
account = await run_in_threadpool(service.create_managed_account, None)
return _render_public_home(service, settings, account, None)
@@ -98,7 +65,6 @@ def robots_txt(settings: SettingsDep) -> str:
"User-agent: *",
f"Disallow: {admin_path}/",
"Disallow: /m/",
"Disallow: /d/",
"Disallow: /u/",
"Disallow: /nic/",
"Disallow: /api/",
+5 -13
View File
@@ -1,11 +1,9 @@
from __future__ import annotations
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update
from ..dependencies import ServiceDep, StoreDep, apply_update_response, authenticate_update
from .path_params import UpdateSlugPath
router = APIRouter()
@@ -25,13 +23,14 @@ def slug_update(
account = store.get_account_by_slug(update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
if not service.hostname_is_managed(str(account["hostname"])):
raise HTTPException(status_code=404, detail="not found")
return apply_update_response(service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr)
@router.get("/nic/update", response_class=PlainTextResponse, response_model=None)
def update(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
credentials: tuple[str | None, str | None] = Depends(authenticate_update),
@@ -45,17 +44,10 @@ def update(
host = (hostname or domain or "").strip().lower().rstrip(".")
if not host:
return PlainTextResponse("nohost", status_code=400)
if settings.allowed_hosts and host not in settings.allowed_hosts:
if not service.hostname_is_managed(host):
return PlainTextResponse("nohost", status_code=403)
supplied_user, supplied_token = credentials
account_ok = store.verify_account(host, supplied_user, supplied_token)
global_secret_ok = (
bool(settings.allowed_hosts)
and host in settings.allowed_hosts
and bool(settings.shared_secret)
and bool(supplied_token)
and secrets.compare_digest(supplied_token, settings.shared_secret)
)
if not global_secret_ok and not account_ok:
if not account_ok:
return PlainTextResponse("badauth", status_code=401)
return apply_update_response(service, request, host, myip, ipaddr, myipv6, ip6addr)