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

+3 -8
View File
@@ -34,11 +34,7 @@ __all__ = [
OPENAPI_TAGS = [
{
"name": "hostnames",
"description": "Create KarlDNS hostnames or hostnames under verified domains.",
},
{
"name": "domains",
"description": "Issue and verify DNS TXT challenges for custom domains.",
"description": "Create KarlDNS hostnames that can be used directly or as CNAME targets.",
},
{
"name": "updates",
@@ -84,8 +80,8 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
title="KarlDNS",
summary="A small self-hosted DynDNS provider for FRITZ!Box routers.",
description=(
"Generate cryptographically random DynDNS endpoints, verify custom domains with DNS TXT "
"records, and publish A/AAAA records through Cloudflare or RFC 2136."
"Generate cryptographically random DynDNS endpoints and publish A/AAAA records "
"for KarlDNS-managed hostnames through Cloudflare or RFC 2136."
),
version="0.1.0",
docs_url="/docs",
@@ -137,7 +133,6 @@ async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None:
try:
await run_in_threadpool(
store.cleanup,
settings.cleanup_challenge_hours,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

-4
View File
@@ -9,14 +9,11 @@ from .ddns_models import DdnsSettings
def settings_from_env() -> DdnsSettings:
return DdnsSettings(
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
shared_secret=os.getenv("DDNS_SHARED_SECRET", ""),
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", ""),
admin_path=os.getenv("DDNS_ADMIN_PATH", "/admin"),
allowed_hosts=_csv_set("DDNS_ALLOWED_HOSTS"),
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
trusted_hosts=_csv_set("DDNS_TRUSTED_HOSTS"),
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_karldns"),
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20),
max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
@@ -30,7 +27,6 @@ def settings_from_env() -> DdnsSettings:
require_dns_provider=_env_flag("DDNS_REQUIRE_DNS_PROVIDER"),
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72),
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080),
cleanup_event_history_hours=_env_int("DDNS_CLEANUP_EVENT_HISTORY_HOURS", 24 * 180),
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
-10
View File
@@ -1,27 +1,17 @@
from __future__ import annotations
from .schemas import (
CustomHostnameRequest,
DomainChallengeRequest,
DomainChallengeResponse,
ErrorResponse,
HostnameCredentials,
MagicHostnameRequest,
ManagedRecord,
RouterUpdateResponse,
VerifyDomainRequest,
VerifyDomainResponse,
)
__all__ = [
"CustomHostnameRequest",
"DomainChallengeRequest",
"DomainChallengeResponse",
"ErrorResponse",
"HostnameCredentials",
"MagicHostnameRequest",
"ManagedRecord",
"RouterUpdateResponse",
"VerifyDomainRequest",
"VerifyDomainResponse",
]
-6
View File
@@ -12,14 +12,11 @@ class DdnsSettings(BaseModel):
model_config = ConfigDict(extra="forbid")
database_path: Path = Field(default=Path("ddns.sqlite3"))
shared_secret: str = Field(default="")
admin_password: str = Field(default="")
admin_path: str = Field(default="/admin")
allowed_hosts: set[str] = Field(default_factory=set)
public_base_url: str = Field(default="http://localhost:8080")
trusted_hosts: set[str] = Field(default_factory=set)
hostname_suffix: str = Field(default="")
verification_prefix: str = Field(default="_karldns")
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
admin_rate_limit_per_minute: int = Field(default=20, ge=1, le=10_000)
max_request_body_bytes: int = Field(default=16_384, ge=1024, le=1_048_576)
@@ -33,7 +30,6 @@ class DdnsSettings(BaseModel):
require_dns_provider: bool = Field(default=False)
cloudflare_api_token: str = Field(default="")
cloudflare_zone_id: str = Field(default="")
cleanup_challenge_hours: int = Field(default=72, ge=1, le=24 * 365)
cleanup_unused_account_hours: int = Field(default=1080, ge=1, le=24 * 365)
cleanup_event_history_hours: int = Field(default=24 * 180, ge=1, le=24 * 3650)
cleanup_interval_seconds: int = Field(default=3600, ge=1, le=24 * 60 * 60)
@@ -69,9 +65,7 @@ class DdnsSettings(BaseModel):
"/admin-tables.js",
"/api",
"/app.js",
"/claim",
"/clipboard.js",
"/d",
"/docs",
"/favicon.ico",
"/healthz",
+3 -5
View File
@@ -156,17 +156,15 @@ def is_rate_limited_path(path: str) -> bool:
limited_paths = {
"/magic",
"/accounts",
"/request-domain",
"/verify-domain",
"/nic/update",
}
return path in limited_paths or path.startswith(("/u/", "/m/", "/d/", "/api/v1/updates/")) or path.startswith(
("/api/v1/hostnames/", "/api/v1/domains/", "/api/v1/management/")
return path in limited_paths or path.startswith(("/u/", "/m/", "/api/v1/updates/")) or path.startswith(
("/api/v1/hostnames/", "/api/v1/management/")
)
def rate_limit_bucket(path: str) -> str:
for prefix in ("/u/", "/m/", "/d/", "/api/v1/updates/", "/api/v1/management/"):
for prefix in ("/u/", "/m/", "/api/v1/updates/", "/api/v1/management/"):
if path.startswith(prefix):
return prefix.rstrip("/")
return path
+7 -110
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from fastapi import HTTPException, Request
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_security import client_ip
from .ddns_store import DdnsStore
@@ -49,56 +49,6 @@ class DdnsService:
continue
raise HTTPException(status_code=500, detail="could not allocate hostname")
def create_custom_account(
self,
hostname: str,
claim_secret: str,
username: str | None = None,
owner_hostname: str | None = None,
) -> dict[str, str]:
normalized = normalize_hostname(hostname, self.settings, expand_suffix=False)
if not normalized:
raise HTTPException(status_code=400, detail="valid custom hostname is required")
if not self.hostname_is_publishable(normalized):
raise HTTPException(status_code=403, detail="hostname is outside configured DNS publishing zones")
if not self.best_verified_parent(normalized, claim_secret, owner_hostname=owner_hostname):
raise HTTPException(status_code=403, detail="verify the parent domain before creating this hostname")
try:
return self.store.create_account(normalized, username)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="hostname already exists") from exc
def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> dict[str, str]:
normalized = normalize_domain(domain)
if not normalized:
raise HTTPException(status_code=400, detail="valid domain is required")
existing = self.store.get_domain_challenge(normalized)
if existing and existing.get("verified_at"):
raise HTTPException(status_code=409, detail="domain is already verified")
return self.store.create_domain_challenge(normalized, owner_hostname=owner_hostname)
def verify_domain(
self,
domain: str,
claim_secret: str,
owner_hostname: str | None = None,
) -> tuple[bool, dict[str, str | None]]:
normalized = normalize_domain(domain)
if not normalized:
raise HTTPException(status_code=400, detail="valid domain is required")
challenge = self.store.get_domain_challenge(normalized, claim_secret=claim_secret, owner_hostname=owner_hostname)
if not challenge:
raise HTTPException(status_code=404, detail="domain challenge not found")
found = dns_txt_contains(self.verification_name(normalized), str(challenge["token"]))
if not found:
challenge = dict(challenge)
challenge["claim_secret"] = claim_secret
return False, challenge
verified = self.store.verify_domain_challenge(normalized, claim_secret, owner_hostname=owner_hostname)
if verified is None:
raise HTTPException(status_code=404, detail="domain challenge not found")
return True, verified
def delete_account(self, hostname: str, source: str) -> None:
try:
delete_dns_records(hostname, self.settings)
@@ -213,49 +163,18 @@ class DdnsService:
slug = urllib.parse.quote(account["management_slug"])
return f"{base}/m/{slug}"
def domain_claim_url(self, challenge: dict[str, str | None]) -> str:
base = self.settings.public_base_url.rstrip("/")
secret = urllib.parse.quote(str(challenge["claim_secret"]))
return f"{base}/d/{secret}"
def random_managed_hostname(self) -> str:
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
if not suffix:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
return f"{secrets.token_hex(6)}.{suffix}"
def best_verified_parent(
self,
hostname: str,
claim_secret: str | None = None,
owner_hostname: str | None = None,
) -> str | None:
labels = hostname.split(".")
for index in range(1, len(labels) - 1):
candidate = ".".join(labels[index:])
if self.store.is_domain_verified(candidate, claim_secret, owner_hostname=owner_hostname):
return candidate
if self.store.is_domain_verified(hostname, claim_secret, owner_hostname=owner_hostname):
return hostname
return None
def verification_name(self, domain: str) -> str:
prefix = self.settings.verification_prefix.strip().strip(".") or "_karldns"
return f"{prefix}.{domain}"
def hostname_is_publishable(self, hostname: str) -> bool:
if not dns_backend_configured(self.settings):
return not self.settings.require_dns_provider
zones = {
zone.strip().lower().strip(".")
for zone in self.settings.dns_zones
if zone.strip()
}
if not zones and self.settings.rfc2136_zone:
zones.add(self.settings.rfc2136_zone.strip().lower().strip("."))
if not zones and self.settings.hostname_suffix:
zones.add(self.settings.hostname_suffix.strip().lower().strip("."))
return bool(zones) and any(hostname == zone or hostname.endswith(f".{zone}") for zone in zones)
def hostname_is_managed(self, hostname: str) -> bool:
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
if not suffix:
return True
normalized = hostname.strip().lower().rstrip(".")
return normalized == suffix or normalized.endswith(f".{suffix}")
def _lock_for(self, hostname: str) -> threading.Lock:
return self._locks[hash(hostname) % self._LOCK_STRIPES]
@@ -320,28 +239,6 @@ def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool =
return hostname
def normalize_domain(value: str) -> str:
domain = value.strip().lower().rstrip(".")
if not _valid_domain_name(domain):
return ""
return domain
def _valid_domain_name(value: str) -> bool:
label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
return bool(re.fullmatch(rf"{label}(\.{label})+", value))
def dns_txt_contains(name: str, expected: str) -> bool:
try:
import dns.exception
import dns.resolver
answers = dns.resolver.resolve(name, "TXT", lifetime=5)
except dns.exception.DNSException:
return False
for answer in answers:
for item in answer.strings:
if item.decode("utf-8", errors="replace") == expected:
return True
return False
-4
View File
@@ -12,10 +12,6 @@ DASHBOARD_PASSWORD_REMOVED = "dashboard_password_removed"
DASHBOARD_UPDATE_URL_ROTATED = "dashboard_update_url_rotated"
DASHBOARD_ROUTER_CREDENTIALS_ROTATED = "dashboard_router_credentials_rotated"
DASHBOARD_HOSTNAME_DELETED = "dashboard_hostname_deleted"
DASHBOARD_DOMAIN_CLAIM_CREATED = "dashboard_domain_claim_created"
DASHBOARD_DOMAIN_CLAIM_VERIFIED = "dashboard_domain_claim_verified"
DASHBOARD_DOMAIN_CLAIM_PENDING = "dashboard_domain_claim_pending"
DASHBOARD_CUSTOM_HOSTNAME_CREATED = "dashboard_custom_hostname_created"
ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted"
ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated"
-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)
-35
View File
@@ -33,41 +33,6 @@ class ManagedRecord(BaseModel):
updated_at: str | None = None
class DomainChallengeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
domain: str = Field(examples=["example.net"])
class DomainChallengeResponse(BaseModel):
domain: str
txt_name: str
txt_value: str
claim_secret: str = Field(description="Secret value used to return to and verify this domain claim.")
class VerifyDomainRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
domain: str = Field(examples=["example.net"])
claim_secret: str
class VerifyDomainResponse(BaseModel):
domain: str
verified: bool
message: str
claim_secret: str = Field(description="Secret value used to continue this domain claim flow.")
class CustomHostnameRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
hostname: str = Field(examples=["home.example.net"])
claim_secret: str
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class RouterUpdateResponse(BaseModel):
status: str = Field(examples=["good"])
hostname: str
+1 -2
View File
@@ -7,7 +7,6 @@ from sqlite3 import Connection
from time import monotonic
from .accounts import AccountsMixin
from .domains import DomainsMixin
from .metrics import MetricsMixin
from .rate_limit import RateLimitCleanupMixin
from .records import RecordsEventsMixin
@@ -15,7 +14,7 @@ from .scheduler import SchedulerMixin
from .schema import connect_database, initialize_schema
class DdnsStore(AccountsMixin, DomainsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
class DdnsStore(AccountsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self._connections = threading.local()
-135
View File
@@ -1,135 +0,0 @@
from __future__ import annotations
import secrets
import sqlite3
from datetime import UTC, datetime
from ..ddns_security import hash_lookup_token
from .types import DomainChallengeRow
class DomainsMixin:
def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> DomainChallengeRow:
token = f"ff-ddns-{secrets.token_urlsafe(24)}"
claim_secret = secrets.token_urlsafe(32)
claim_hash = hash_lookup_token(claim_secret)
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
existing = conn.execute(
"SELECT verified_at, owner_hostname FROM domain_challenges WHERE domain = ?",
(domain,),
).fetchone()
if existing and existing[0]:
raise sqlite3.IntegrityError("domain is already verified")
if existing and existing[1] and existing[1] != owner_hostname:
raise sqlite3.IntegrityError("domain claim belongs to another dashboard")
conn.execute(
"""
INSERT INTO domain_challenges(domain, token, claim_hash, owner_hostname, created_at, verified_at)
VALUES (?, ?, ?, ?, ?, NULL)
ON CONFLICT(domain) DO UPDATE SET
token=excluded.token,
claim_hash=excluded.claim_hash,
owner_hostname=excluded.owner_hostname,
created_at=excluded.created_at,
verified_at=NULL
""",
(domain, token, claim_hash, owner_hostname, now),
)
return {"domain": domain, "token": token, "claim_secret": claim_secret}
def verify_domain_challenge(
self,
domain: str,
claim_secret: str,
owner_hostname: str | None = None,
) -> DomainChallengeRow | None:
now = datetime.now(UTC).isoformat()
claim_hash = hash_lookup_token(claim_secret)
with self.connect() as conn:
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ? AND claim_hash = ?"
params = [domain, claim_hash]
if owner_hostname is not None:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
challenge = conn.execute(sql, tuple(params)).fetchone()
if not challenge:
return None
update_sql = "UPDATE domain_challenges SET verified_at = ? WHERE domain = ? AND claim_hash = ?"
update_params = [now, domain, claim_hash]
if owner_hostname is not None:
update_sql += " AND owner_hostname = ?"
update_params.append(owner_hostname)
cursor = conn.execute(update_sql, tuple(update_params))
if cursor.rowcount != 1:
return None
result = dict(challenge)
result["verified_at"] = now
result["claim_secret"] = claim_secret
return result
def get_domain_challenge(
self,
domain: str,
claim_secret: str | None = None,
owner_hostname: str | None = None,
) -> DomainChallengeRow | None:
with self.connect() as conn:
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ?"
params = [domain]
if claim_secret is not None:
sql += " AND claim_hash = ?"
params.append(hash_lookup_token(claim_secret))
if owner_hostname is not None:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
challenge = conn.execute(sql, tuple(params)).fetchone()
return dict(challenge) if challenge else None
def get_domain_challenge_by_secret(self, claim_secret: str) -> DomainChallengeRow | None:
with self.connect() as conn:
challenge = conn.execute(
"SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE claim_hash = ?",
(hash_lookup_token(claim_secret),),
).fetchone()
if not challenge:
return None
result = dict(challenge)
result["claim_secret"] = claim_secret
return result
def list_domain_challenges(self, limit: int = 100, offset: int = 0) -> list[DomainChallengeRow]:
with self.connect() as conn:
rows = conn.execute(
"""
SELECT domain, owner_hostname, created_at, verified_at
FROM domain_challenges
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_domain_challenges(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM domain_challenges").fetchone()
return int(row[0])
def is_domain_verified(
self,
domain: str,
claim_secret: str | None = None,
owner_hostname: str | None = None,
) -> bool:
with self.connect() as conn:
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
params = [domain]
if claim_secret is not None:
sql += " AND claim_hash = ?"
params.append(hash_lookup_token(claim_secret))
if owner_hostname is not None:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
verified_at = conn.execute(sql, tuple(params)).fetchone()
return bool(verified_at and verified_at[0])
+4 -11
View File
@@ -13,7 +13,7 @@ class RateLimitCleanupMixin:
with self.connect() as conn:
rows = conn.execute(
"""
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
SELECT created_at, unused_accounts_deleted
FROM cleanup_runs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
@@ -116,19 +116,13 @@ class RateLimitCleanupMixin:
def cleanup(
self,
challenge_hours: int,
unused_account_hours: int,
event_history_hours: int = 24 * 180,
) -> dict[str, int]:
self.flush_rate_limits()
challenge_cutoff = (datetime.now(UTC) - timedelta(hours=challenge_hours)).isoformat()
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
with self.connect() as conn:
challenge_cursor = conn.execute(
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?",
(challenge_cutoff,),
)
account_cursor = conn.execute(
"""
DELETE FROM accounts
@@ -151,13 +145,12 @@ class RateLimitCleanupMixin:
conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 60,))
conn.execute(
"""
INSERT INTO cleanup_runs(created_at, domain_challenges_deleted, unused_accounts_deleted)
VALUES (?, ?, ?)
INSERT INTO cleanup_runs(created_at, unused_accounts_deleted)
VALUES (?, ?)
""",
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
(datetime.now(UTC).isoformat(), account_cursor.rowcount),
)
return {
"domain_challenges": challenge_cursor.rowcount,
"unused_accounts": account_cursor.rowcount,
"event_rollups": rollup_cursor.rowcount,
"update_events": event_cursor.rowcount,
+26 -26
View File
@@ -90,11 +90,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
CREATE TABLE IF NOT EXISTS cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
domain_challenges_deleted INTEGER NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
_migrate_cleanup_runs_schema(conn)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute(
"""
@@ -167,31 +167,6 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_hostname_username ON accounts(hostname, username)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS domain_challenges (
domain TEXT PRIMARY KEY,
token TEXT NOT NULL,
claim_hash TEXT,
owner_hostname TEXT,
created_at TEXT NOT NULL,
verified_at TEXT
)
"""
)
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
_ensure_column(conn, "domain_challenges", "owner_hostname", "TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_owner_hostname ON domain_challenges(owner_hostname)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_owner_verified ON domain_challenges(owner_hostname, verified_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_claim_hash ON domain_challenges(claim_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_created_at ON domain_challenges(created_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_domain_challenges_unverified_created
ON domain_challenges(created_at)
WHERE verified_at IS NULL
"""
)
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.execute("PRAGMA optimize")
@@ -201,3 +176,28 @@ def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition
columns = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
if column not in columns:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def _migrate_cleanup_runs_schema(conn: sqlite3.Connection) -> None:
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
if columns <= {"id", "created_at", "unused_accounts_deleted"}:
return
conn.execute("DROP INDEX IF EXISTS idx_cleanup_runs_created_at")
conn.execute(
"""
CREATE TABLE cleanup_runs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
conn.execute(
"""
INSERT INTO cleanup_runs_new(id, created_at, unused_accounts_deleted)
SELECT id, created_at, unused_accounts_deleted
FROM cleanup_runs
"""
)
conn.execute("DROP TABLE cleanup_runs")
conn.execute("ALTER TABLE cleanup_runs_new RENAME TO cleanup_runs")
-10
View File
@@ -18,15 +18,6 @@ class AccountRow(TypedDict, total=False):
password: str
class DomainChallengeRow(TypedDict, total=False):
domain: str
token: str
claim_secret: str
owner_hostname: str | None
created_at: str
verified_at: str | None
class UpdateEventRow(TypedDict, total=False):
hostname: str
ipv4: str | None
@@ -40,7 +31,6 @@ class UpdateEventRow(TypedDict, total=False):
class CleanupRunRow(TypedDict):
created_at: str
domain_challenges_deleted: int
unused_accounts_deleted: int
+4 -5
View File
@@ -107,11 +107,11 @@ def is_admin_path(path: str, settings: DdnsSettings) -> bool:
def is_secret_response(request: Request) -> bool:
path = request.url.path
settings = request.app.state.settings
if is_admin_path(path, settings) or path.startswith(("/m/", "/d/", "/u/")):
if is_admin_path(path, settings) or path.startswith(("/m/", "/u/")):
return True
if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
if path.startswith("/api/v1/hostnames/"):
return request.method in {"POST", "DELETE"}
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
return (request.method == "POST" and path in {"/magic", "/accounts"}) or path == "/nic/update"
def is_noindex_response(request: Request) -> bool:
@@ -120,7 +120,7 @@ def is_noindex_response(request: Request) -> bool:
return True
if path in {"/docs", "/redoc", "/openapi.json", "/healthz", "/ip"}:
return True
if path.startswith(("/api/", "/m/", "/d/", "/u/", "/nic/")):
if path.startswith(("/api/", "/m/", "/u/", "/nic/")):
return True
return is_admin_path(path, request.app.state.settings)
@@ -154,7 +154,6 @@ def redacted_access_path(path: str) -> str:
secret_prefixes = {
"/u": "/u/<secret>",
"/m": "/m/<secret>",
"/d": "/d/<secret>",
"/api/v1/updates": "/api/v1/updates/<secret>",
"/api/v1/management": "/api/v1/management/<secret>",
}
+200 -11
View File
@@ -544,17 +544,36 @@ select:focus-visible {
background: var(--rp-field);
}
.manual-code {
overflow-x: auto;
padding: 16px;
border: 1px solid var(--rp-line);
border-radius: 12px;
color: var(--rp-terminal-ink);
background: var(--rp-terminal);
font: 400 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
.copy-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.copy-button .copy-icon,
.copy-button .check-icon,
.copy-icon-button .copy-icon,
.copy-icon-button .check-icon,
.copy-menu-item .copy-icon,
.copy-menu-item .check-icon,
.copy-menu .more-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.check-icon,
.is-copied .copy-icon {
display: none;
}
.is-copied .check-icon {
display: block;
}
.terminal-card {
position: relative;
display: grid;
gap: 8px;
padding: 18px 20px;
@@ -565,6 +584,14 @@ select:focus-visible {
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.code-window {
padding-right: 56px;
}
.manual-code {
overflow-x: auto;
}
.terminal-card code {
display: block;
overflow-wrap: anywhere;
@@ -572,13 +599,151 @@ select:focus-visible {
font: inherit;
}
.copy-icon-button {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: 999px;
color: var(--rp-muted);
background: transparent;
}
.copy-icon-button:hover,
.copy-icon-button:focus-visible {
color: var(--rp-ink);
background: var(--rp-surface-alt);
}
.copy-actions {
display: flex;
align-items: center;
justify-content: flex-start;
}
.copy-menu {
position: relative;
display: inline-flex;
}
.copy-menu > summary {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--rp-line);
border-radius: 999px;
color: var(--rp-muted);
background: var(--rp-surface);
cursor: pointer;
list-style: none;
}
.copy-menu > summary::-webkit-details-marker {
display: none;
}
.copy-menu > summary:hover,
.copy-menu > summary:focus-visible {
color: var(--rp-ink);
background: var(--rp-surface-alt);
}
.copy-menu-popover {
position: absolute;
z-index: 20;
top: calc(100% + 8px);
left: 0;
display: grid;
min-width: 220px;
gap: 4px;
padding: 8px;
border: 1px solid var(--rp-line);
border-radius: 12px;
background: var(--rp-surface);
}
.copy-menu-item {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
min-height: 36px;
padding: 8px 10px;
border: 0;
border-radius: 10px;
color: var(--rp-ink);
background: transparent;
font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
text-align: left;
}
.copy-menu-item:hover,
.copy-menu-item:focus-visible {
background: var(--rp-surface-alt);
}
.site-footer {
padding: 32px 0;
padding: 28px 0;
background: var(--rp-canvas);
}
.site-footer .nav-link:hover {
color: var(--rp-ink) !important;
.footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.footer-made {
flex: 0 0 auto;
color: var(--rp-muted);
font-size: 13px;
line-height: 1.4;
}
.footer-links {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px 18px;
}
.footer-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--rp-muted);
font-size: 13px;
line-height: 1.4;
text-decoration: none;
}
.footer-link svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
opacity: 0.8;
}
.footer-link:hover,
.footer-link:focus-visible {
color: var(--rp-ink);
}
.footer-link:focus-visible {
border-radius: 999px;
outline: 2px solid var(--rp-focus-ring);
outline-offset: 4px;
}
@media (max-width: 860px) {
@@ -679,6 +844,30 @@ select:focus-visible {
border-radius: 0 0 14px 14px;
}
.card .copy-icon-button,
.copy-icon-button {
width: 34px;
}
.card .copy-menu > summary,
.copy-menu > summary {
width: 40px;
}
.copy-menu-popover {
right: auto;
left: 0;
}
.footer-inner {
align-items: flex-start;
flex-direction: column;
}
.footer-links {
justify-content: flex-start;
}
h1 {
font-size: 28px;
line-height: 1.15;
+108 -19
View File
@@ -5,6 +5,26 @@ import json
from .html_utils import SafeHtml, attr, escape, join_html, trusted
def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
if name == "check":
path = '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75 10.5 18.75 19.5 5.25"/>'
elif name == "more":
path = (
'<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75h.01M12 12h.01M12 17.25h.01"/>'
)
else:
path = (
'<path stroke-linecap="round" stroke-linejoin="round" '
'd="M16.5 8.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v8.25A2.25 2.25 0 0 0 6 16.5h2.25'
'm8.25-8.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-7.5A2.25 2.25 0 0 1 8.25 18'
'v-7.5A2.25 2.25 0 0 1 10.5 8.25h6Z"/>'
)
return trusted(
f'<svg class="{attr(css_class)}" aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" '
f'fill="none" stroke="currentColor" stroke-width="1.5">{path}</svg>'
)
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
if label:
@@ -63,16 +83,66 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtm
<span class="input-group-text copy-label"><span>{safe_label}</span>{badge}</span>
{value_field}
{reveal_button}
<button type="button" class="btn btn-outline-secondary" data-copy="{safe_value}" aria-label="Copy {safe_label}">Copy</button>
<button type="button" class="btn btn-outline-secondary copy-button" data-copy="{safe_value}" aria-label="Copy {safe_label}">
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button>
</div>
"""
)
def _copy_button(label: str, value: str) -> SafeHtml:
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
return trusted(f'<button type="button" class="btn btn-outline-primary rounded-pill" data-copy="{safe_value}">{safe_label}</button>')
safe_style = attr(style)
return trusted(
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_icon_button(label: str, value: str) -> SafeHtml:
safe_label = escape(label)
safe_value = attr(value)
return trusted(
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
)
def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
safe_label = escape(label)
buttons = []
for item_label, item_value in items:
buttons.append(
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
)
return trusted(
f"""
<details class="copy-menu">
<summary aria-label="{safe_label}" title="{safe_label}">
{_svg_icon("more", "more-icon")}
</summary>
<div class="copy-menu-popover" role="menu">
{''.join(buttons)}
</div>
</details>
"""
)
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "") -> SafeHtml:
safe_class = f" {attr(class_name)}" if class_name else ""
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
return trusted(
f"""
<div class="terminal-card code-window{safe_class}">
{_copy_icon_button(copy_label, commands)}
{code_lines}
</div>
"""
)
def _manual_update_commands(update_url: str) -> str:
@@ -87,7 +157,6 @@ def _manual_update_commands(update_url: str) -> str:
def _manual_update_panel(commands: str) -> SafeHtml:
safe_commands = escape(commands)
return trusted(
f"""
<div class="card card-body gap-3 mt-4">
@@ -95,8 +164,7 @@ def _manual_update_panel(commands: str) -> SafeHtml:
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
</div>
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
{_copy_button("Copy curl", commands)}
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
"""
)
@@ -125,8 +193,8 @@ def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Domain name and CNAME target can be shared.
<strong>Secret:</strong> Update URLs, router password, dashboard link, and claim links can update or manage this hostname.
<strong>Public:</strong> Hostname and CNAME target can be shared.
<strong>Secret:</strong> Update URLs, router password, and dashboard link can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
@@ -159,15 +227,33 @@ def _site_footer() -> SafeHtml:
return trusted(
"""
<footer class="site-footer border-top">
<div class="container d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row">
<div class="small text-secondary">Made by Karl</div>
<nav class="nav justify-content-center gap-3" aria-label="Footer links">
<a class="nav-link p-0 small text-secondary" href="https://github.com/StasonJatham/karldns.de" rel="noopener">GitHub</a>
<a class="nav-link p-0 small text-secondary" href="https://karl.fail" rel="noopener">karl.fail</a>
<a class="nav-link p-0 small text-secondary" href="https://karlcom.de" rel="noopener">karlcom.de</a>
<a class="nav-link p-0 small text-secondary" href="/docs">API docs</a>
<a class="nav-link p-0 small text-secondary" href="/redoc">ReDoc</a>
<a class="nav-link p-0 small text-secondary" href="/privacy">Legal & privacy</a>
<div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
<nav class="footer-links" aria-label="Footer links">
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A12 12 0 0 0 8.2 23.9c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.4-4-1.4-.5-1.3-1.2-1.7-1.2-1.7-1-.7.1-.7.1-.7 1.1.1 1.7 1.2 1.7 1.2 1 .1.6 2.7 3.4 1.9.1-.7.4-1.2.7-1.5-2.6-.3-5.4-1.3-5.4-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.4 5.9.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6A12 12 0 0 0 12 .5Z"/></svg>
<span>GitHub</span>
</a>
<a class="footer-link" href="https://karl.fail" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6.5h16M4 12h10M4 17.5h7"/><path stroke-linecap="round" d="M18 15.5h.01M18 18.5h.01"/></svg>
<span>karl.fail</span>
</a>
<a class="footer-link" href="https://karlcom.de" rel="noopener">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="8.5"/><path stroke-linecap="round" d="M3.5 12h17M12 3.5c2.2 2.3 3.3 5.1 3.3 8.5S14.2 18.2 12 20.5M12 3.5C9.8 5.8 8.7 8.6 8.7 12S9.8 18.2 12 20.5"/></svg>
<span>karlcom.de</span>
</a>
<a class="footer-link" href="/docs">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M6 4.5h8.5L18 8v11.5H6z"/><path stroke-linecap="round" stroke-linejoin="round" d="M14.5 4.5V8H18M8.5 12h7M8.5 15h7"/></svg>
<span>API docs</span>
</a>
<a class="footer-link" href="/redoc">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="m9 8-4 4 4 4M15 8l4 4-4 4"/></svg>
<span>ReDoc</span>
</a>
<a class="footer-link" href="/privacy">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3.5 5.5 6v5.5c0 4.1 2.7 7.6 6.5 9 3.8-1.4 6.5-4.9 6.5-9V6z"/><path stroke-linecap="round" stroke-linejoin="round" d="m9 12 2 2 4-4"/></svg>
<span>Legal & privacy</span>
</a>
</nav>
</div>
</footer>
@@ -242,10 +328,10 @@ def _page(
<link rel="manifest" href="/site.webmanifest">
{preload_logo_html}
<script src="/theme.js?v=2"></script>
<script src="/clipboard.js?v=2" defer></script>
<script src="/clipboard.js?v=3" defer></script>
{extra_scripts_html}
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
<link href="/app.css?v=2" rel="stylesheet">
<link href="/app.css?v=5" rel="stylesheet">
{extra_styles_html}
{structured_data_html}
</head>
@@ -257,7 +343,10 @@ def _page(
__all__ = [
"SafeHtml",
"_copy_button",
"_copy_icon_button",
"_copy_menu",
"_copy_row",
"_code_window",
"_fritzbox_fields",
"_manual_update_commands",
"_manual_update_panel",
+4 -39
View File
@@ -11,7 +11,6 @@ from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_domain_claim_row,
_event_row,
)
@@ -34,7 +33,6 @@ def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: list[dict[str, str | int | None]],
domain_challenges: list[dict[str, str | None]],
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
@@ -52,24 +50,13 @@ def _render_admin_page(
rows = "\n".join(_account_row(account, csrf=csrf, admin_path=admin_path) for account in accounts) or """
<tr class="empty-row"><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 class="empty-row"><td colspan="5" class="empty">No domain claims yet.</td></tr>
"""
event_rows = "\n".join(_event_row(event) for event in update_events) or """
<tr class="empty-row"><td colspan="7" class="empty">No update events yet.</td></tr>
"""
accounts_count = escape(table_counts.get("accounts", len(accounts)))
domain_count = escape(table_counts.get("domain_challenges", len(domain_challenges)))
event_count = escape(table_counts.get("update_events", len(update_events)))
metrics_html = _metrics_panel(service_metrics)
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
domain_pager = _server_page_links(
admin_path,
"domain_claims_offset",
table_offsets.get("domain_challenges", 0),
table_limit,
table_counts.get("domain_challenges", 0),
)
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
return _page(
@@ -81,7 +68,7 @@ def _render_admin_page(
<div class="container">
<p class="eyebrow">KarlDNS</p>
<h1>Operator console</h1>
<p class="lead">Review hostnames, domain checks, updates, cleanup, and secret changes.</p>
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
</div>
</section>
@@ -115,7 +102,7 @@ def _render_admin_page(
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
<tr><th scope="col">Domain</th><th scope="col">User</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
<tr><th scope="col">Hostname</th><th scope="col">User</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
@@ -125,27 +112,6 @@ def _render_admin_page(
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Domain claims</p>
<h2>Domain verification</h2>
<p class="section-copy mb-0">Showing {escape(len(domain_challenges))} of {domain_count}.</p>
</div>
</div>
{_admin_table_toolbar("domain-claims", "Search domain claims")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th scope="col">Domain</th><th scope="col">Owner dashboard</th><th scope="col">Status</th><th scope="col">Created</th><th scope="col">Verified</th></tr></thead>
<tbody>{domain_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{domain_pager}
</div>
</section>
<section class="section">
<div class="container">
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
@@ -158,7 +124,7 @@ def _render_admin_page(
{_admin_table_toolbar("update-events", "Search update events")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th scope="col">Time</th><th scope="col">Domain</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
<thead><tr><th scope="col">Time</th><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
@@ -207,7 +173,6 @@ def _cleanup_panel(
last_error = task.get("last_error") or ""
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
domains_removed = last_cleanup.get("domain_challenges_deleted", 0)
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
error_html = (
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
@@ -237,7 +202,7 @@ def _cleanup_panel(
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row">
<div>
<h3 class="h5 mb-2">Cleanup</h3>
<p class="section-copy mb-1">Last cleanup removed {escape(domains_removed)} domain claims and {escape(accounts_removed)} unused hostnames.</p>
<p class="section-copy mb-1">Last cleanup removed {escape(accounts_removed)} unused hostnames.</p>
{error_html}
</div>
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
@@ -66,32 +66,6 @@ def _admin_table_pagination() -> str:
"""
def _domain_claim_row(challenge: dict[str, str | None]) -> str:
domain = escape(challenge["domain"])
owner = escape(challenge.get("owner_hostname") or "-")
created = escape(challenge.get("created_at") or "-")
verified = escape(challenge.get("verified_at") or "-")
status = "Verified" if challenge.get("verified_at") else "Pending"
return f"<tr><td>{domain}</td><td>{owner}</td><td>{status}</td><td>{created}</td><td>{verified}</td></tr>"
def _cleanup_row(run: dict[str, str | int]) -> str:
created = escape(run.get("created_at") or "-")
domains = escape(run.get("domain_challenges_deleted") or 0)
accounts = escape(run.get("unused_accounts_deleted") or 0)
return f"<tr><td>{created}</td><td>{domains}</td><td>{accounts}</td></tr>"
def _scheduled_task_row(task: dict[str, str | int | None]) -> str:
name = escape(task.get("name") or "-")
status = escape(task.get("last_status") or "-")
next_run = escape(task.get("next_run_at") or "-")
lease_expires = escape(task.get("lease_expires_at") or "-")
last_finished = escape(task.get("last_finished_at") or "-")
last_error = escape(task.get("last_error") or "-")
return f"<tr><td>{name}</td><td>{status}</td><td>{next_run}</td><td>{lease_expires}</td><td>{last_finished}</td><td>{last_error}</td></tr>"
def _event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
hostname = escape(event.get("hostname") or "-")
+4 -101
View File
@@ -4,7 +4,7 @@ from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
_copy_button,
_copy_menu,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
@@ -24,7 +24,6 @@ def _render_management_page(
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
challenge: dict[str, str | None] | None = None,
created_account: dict[str, str] | None = None,
) -> str:
account_for_url = {
@@ -48,7 +47,6 @@ def _render_management_page(
"""
message_html = _management_message(message)
rotated_credentials = _credentials_panel(service, created_account)
domain_claim = _management_domain_claim_panel(service, management_slug, csrf_token, challenge)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
return _page(
"KarlDNS management",
@@ -71,12 +69,11 @@ def _render_management_page(
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<h2>Router update settings</h2>
<p class="section-copy">Use the Update-URL if your router has one URL field. Username and password are only for routers that require them. For your own subdomain, create a CNAME to this hostname.</p>
<p class="section-copy">Use the Update-URL if your router has one URL field. Username and password are only for routers that require them. Optional: point another DNS name at this hostname with a CNAME.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap mt-3">
{_copy_button("Copy router fields", fritzbox_fields)}
{_copy_button("Copy CNAME", cname_record)}
<div class="copy-actions mt-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])}
</div>
</div>
<div class="col-lg-6 d-grid gap-2">
@@ -109,7 +106,6 @@ def _render_management_page(
</div>
</div>
</section>
{domain_claim}
{dashboard_password_panel}
<section class="section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
@@ -275,99 +271,6 @@ def _render_management_unlock_page(
)
def _management_domain_claim_panel(
service: DdnsService,
management_slug: str,
csrf_token: str,
challenge: dict[str, str | None] | None,
) -> SafeHtml:
create_claim = trusted(
f"""
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Custom domain</p>
<h2>Use your domain</h2>
<p class="section-copy">Create a TXT record, verify it here, then create a hostname under that domain.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/request" class="d-grid gap-3" aria-label="Create dashboard domain claim">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create claim</button>
</form>
</div>
"""
)
if not challenge:
return trusted(
f"""
<section class="section">
<div class="container narrow">{create_claim}</div>
</section>
"""
)
domain = str(challenge["domain"])
claim_secret = str(challenge.get("claim_secret") or "")
verification_name = service.verification_name(domain)
token = str(challenge["token"])
claim_url = service.domain_claim_url(challenge)
status = "Verified" if challenge.get("verified_at") else "Waiting for TXT"
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
create_hostname = _management_custom_hostname_form(management_slug, claim_secret, csrf_token) if challenge.get("verified_at") else trusted("")
return trusted(
f"""
<section class="section" id="custom-domain">
<div class="container narrow d-grid gap-4">
{create_claim}
<div class="card card-body gap-3">
<div>
<p class="eyebrow">Domain claim</p>
<h2>{escape(domain)}</h2>
<p class="section-copy">Status: {escape(status)}</p>
<p class="small text-secondary mb-0">This claim belongs to this dashboard. Other dashboards cannot use it.</p>
</div>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. Claim secret and private claim link are secret.</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy TXT", txt_bundle)}
</div>
<div class="d-grid gap-2">
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Claim secret", claim_secret, "Secret")}
{_copy_row("Private claim link", claim_url, "Secret")}
</div>
<form method="post" action="/m/{attr(management_slug)}/domains/verify" class="d-flex gap-2 flex-wrap" aria-label="Verify dashboard domain claim">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="domain" value="{attr(domain)}">
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<button type="submit" class="btn btn-primary rounded-pill">Verify</button>
</form>
{create_hostname}
</div>
</div>
</section>
"""
)
def _management_custom_hostname_form(management_slug: str, claim_secret: str, csrf_token: str | None = None) -> SafeHtml:
csrf_field = f'<input type="hidden" name="csrf" value="{attr(csrf_token)}">' if csrf_token else ""
return trusted(
f"""
<form method="post" action="/m/{attr(management_slug)}/domains/hostnames" class="d-grid gap-3" aria-label="Create verified custom hostname">
{csrf_field}
<input type="hidden" name="claim_secret" value="{attr(claim_secret)}">
<label>Hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
"""
)
def _user_event_row(event: dict[str, str | int | None]) -> str:
created = escape(event.get("created_at") or "-")
status = escape(event.get("status") or "-")
+31 -35
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import escape, trusted
from .templates import _message_band, _page, _top_nav
from .templates_public_sections import _credentials_panel, _custom_domain_flow, _linux_installer_section
from .templates import _code_window, _message_band, _page, _top_nav
from .templates_public_sections import _credentials_panel, _linux_installer_section
def _render_public_home(
@@ -12,15 +12,24 @@ def _render_public_home(
settings: DdnsSettings,
created_account: dict[str, str] | None,
message: str | None,
challenge: dict[str, str | None] | None = None,
) -> str:
base_url = settings.public_base_url.rstrip("/")
suffix = escape(settings.hostname_suffix or "your DynDNS domain")
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
suffix = escape(hostname_suffix)
demo_update_url = escape(f"{base_url}/u/<secret>?myip=<ipaddr>")
cname_example = f"home.example.com. CNAME your-name.{hostname_suffix}."
create_demo = "$ curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'\ngood 203.0.113.10"
manual_demo = "\n".join(
[
"# explicit IPv4",
"curl -sS 'https://karldns.de/u/<secret>?myip=203.0.113.10'",
"# source IP fallback",
"curl -sS 'https://karldns.de/u/<secret>'",
]
)
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)
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, custom domains, Linux cron jobs, and home networks."
message_html = _message_band(message) if message else ""
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
return _page(
"KarlDNS",
trusted(f"""
@@ -33,7 +42,7 @@ def _render_public_home(
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">KarlDNS gives you a private DynDNS update URL under {suffix}. Add it to your router, or use it from a script.</p>
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<a class="btn btn-primary rounded-pill" href="#create">Create hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
@@ -47,12 +56,12 @@ def _render_public_home(
<div class="text-center mb-4">
<p class="eyebrow">Hostname</p>
<h2>No account needed.</h2>
<p class="section-copy mx-auto">Use a KarlDNS hostname now, or verify your own domain when you need it.</p>
<p class="section-copy mx-auto">Create a KarlDNS hostname. Use it directly, or point your own DNS name at it with a CNAME.</p>
</div>
<div class="row g-4 align-items-stretch">
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">KarlDNS domain</p>
<p class="eyebrow">KarlDNS hostname</p>
<h3>Create a hostname</h3>
<p class="section-copy mb-0">You get a hostname, a private update URL, and router fields if your router needs them.</p>
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
@@ -62,38 +71,25 @@ def _render_public_home(
</div>
<div class="col-lg-6">
<div class="card card-body h-100 gap-3">
<p class="eyebrow">Use your own domain</p>
<h3>Verify your domain</h3>
<p class="section-copy mb-0">Add one TXT record first. KarlDNS only updates domains it can verify.</p>
<form method="post" action="/request-domain" class="d-grid gap-3" aria-label="Request a custom domain TXT challenge">
<label>Domain
<input class="form-control" name="domain" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary rounded-pill">Create TXT record</button>
</form>
<p class="eyebrow">Optional DNS alias</p>
<h3>Add a CNAME</h3>
<p class="section-copy mb-0">After creation, add a CNAME at your DNS provider:</p>
{_code_window(cname_example, copy_label="CNAME example")}
<p class="small text-secondary mb-0">KarlDNS updates only the generated hostname. The external DNS name follows it through CNAME.</p>
</div>
</div>
</div>
<div class="terminal-card mt-4" aria-hidden="true">
<code>$ curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
<code>good 203.0.113.10</code>
</div>
{_code_window(create_demo, copy_label="update example", class_name="mt-4")}
</div>
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from a router or script.</h2>
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
<div class="terminal-card text-start mt-4">
<code># explicit IPv4</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;?myip=203.0.113.10'</code>
<code># source IP fallback</code>
<code>curl -sS 'https://karldns.de/u/&lt;secret&gt;'</code>
</div>
{_code_window(manual_demo, copy_label="manual update examples", class_name="text-start mt-4")}
</div>
</section>
{_linux_installer_section()}
{challenge_html}
</main>
"""),
description=description,
@@ -114,7 +110,7 @@ def _render_public_home(
"featureList": [
"Dynamic DNS for FRITZ!Box routers",
"Accountless private update URLs",
"Custom domain TXT verification",
"CNAME target for external DNS names",
"Linux cron updater",
],
},
@@ -122,7 +118,7 @@ def _render_public_home(
def _render_privacy_page(settings: DdnsSettings) -> str:
base_url = settings.public_base_url.rstrip("/")
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, DNS verification records, IP history, and cleanup behavior."
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
return _page(
"Legal and Privacy",
trusted(f"""
@@ -139,15 +135,15 @@ def _render_privacy_page(settings: DdnsSettings) -> str:
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, TXT verification records, recent IP history, timestamps, and service events.</p>
<p class="section-copy">KarlDNS stores hostnames, update links, protected dashboard tokens, protected router passwords, recent IP history, timestamps, and service events.</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Dashboard links, update URLs, router passwords, full update URLs, and claim links are secrets. Anyone with them may update or manage the hostname. Passwords and dashboard lookup values are not stored as readable text.</p>
<p class="section-copy">Dashboard links, update URLs, router passwords, and full update URLs are secrets. Anyone with them may update or manage the hostname. Passwords and dashboard lookup values are not stored as readable text.</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes abandoned setup records and unused hostnames. Server logs may also include IP addresses and request details.</p>
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.</p>
</div>
<div>
<h2>Contact</h2>
+15 -111
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .html_utils import SafeHtml, escape, trusted
from .templates import (
_copy_button,
_code_window,
_copy_menu,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
@@ -18,7 +19,6 @@ def _linux_installer_section() -> SafeHtml:
system_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sudo sh"
user_install = "curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user"
clean_uninstall = "curl -fsSL https://karldns.de/uninstall.sh | sh -s -- --mode user --delete-config"
all_commands = "\n".join([system_install, system_uninstall, user_install, clean_uninstall])
return trusted(
f"""
<section class="section" id="linux-installer">
@@ -29,15 +29,16 @@ def _linux_installer_section() -> SafeHtml:
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates a hostname, installs the updater, schedules it, and prints your private dashboard link.</p>
</div>
<div class="card card-body gap-3">
<div class="terminal-card">
<code>{escape(system_install)}</code>
<code>{escape(system_uninstall)}</code>
<div>
<h3>Install</h3>
<p class="section-copy">System install for most Linux servers.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy install", system_install)}
{_copy_button("Copy uninstall", system_uninstall)}
{_copy_button("Copy all commands", all_commands)}
{_code_window(system_install, copy_label="install command")}
<div>
<h3>Uninstall</h3>
<p class="section-copy">Stops the updater and leaves your private URLs on disk.</p>
</div>
{_code_window(system_uninstall, copy_label="uninstall command")}
<div class="alert alert-info my-0 py-2 small" role="note">
Uninstall keeps your private URLs by default. Use <code>--delete-config</code> only when you want to remove everything.
</div>
@@ -45,67 +46,8 @@ def _linux_installer_section() -> SafeHtml:
<h3>User install</h3>
<p class="section-copy">Use this when you do not want a system-wide install.</p>
</div>
<div class="terminal-card">
<code>{escape(user_install)}</code>
<code>{escape(clean_uninstall)}</code>
</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy user install", user_install)}
{_copy_button("Copy clean user uninstall", clean_uninstall)}
</div>
</div>
</div>
</section>
"""
)
def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> SafeHtml:
if not challenge:
return trusted("")
domain = attr(challenge["domain"])
token = str(challenge["token"])
claim_secret = attr(challenge.get("claim_secret") or "")
verification_name = service.verification_name(str(challenge["domain"]))
claim_url = service.domain_claim_url(challenge)
status = escape(message or "Add this TXT record at your DNS provider, then check again. DNS can take a few minutes.")
txt_bundle = f"TXT name: {verification_name}\nTXT value: {token}"
challenge_html = trusted(
f"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3">
<span class="badge text-bg-primary rounded-circle align-self-start">1</span>
<h3>Add the TXT record</h3>
<p class="small text-secondary mb-0 mt-2">{status}</p>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public. The private claim link is secret.</div>
{_copy_button("Copy TXT", txt_bundle)}
{_copy_row("TXT name", verification_name, "Public")}
{_copy_row("TXT value", token, "Public")}
{_copy_row("Private claim link", claim_url, "Secret")}
<form method="post" action="/verify-domain" class="mt-3" aria-label="Check DNS TXT verification">
<input type="hidden" name="domain" value="{domain}">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<button type="submit" class="btn btn-primary rounded-pill">Check DNS record</button>
</form>
</div>
</div>
"""
)
create_form = _custom_hostname_form(claim_secret) if challenge.get("verified_at") else _locked_custom_hostname_card()
return trusted(
f"""
<section class="section" id="custom-domain">
<div class="container narrow">
<div class="text-center">
<p class="eyebrow">Custom domain</p>
<h2>Verify your domain</h2>
<p class="section-copy mx-auto">Use this when KarlDNS should update records inside your domain. For a simpler setup, create a KarlDNS hostname and point a CNAME to it.</p>
</div>
<div class="row g-3 mt-4 justify-content-center">
{challenge_html}
{create_form}
{_code_window(user_install, copy_label="user install command")}
{_copy_menu("More copy options", [("Clean user uninstall", clean_uninstall)])}
</div>
</div>
</section>
@@ -134,10 +76,8 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
<p class="section-copy">Use the Update-URL directly. Username and password are only for routers that require separate fields.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap my-3">
{_copy_button("Copy router fields", fritzbox_fields)}
{_copy_button("Copy full URL", credentialed_update_url)}
{_copy_button("Copy CNAME", cname_record)}
<div class="copy-actions my-3">
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Full update URL", credentialed_update_url), ("CNAME", cname_record)])}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
@@ -153,39 +93,3 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
</section>
"""
)
def _custom_hostname_form(claim_secret: str) -> SafeHtml:
return trusted(
f"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create hostname</h3>
<form method="post" action="/accounts" class="d-grid gap-3" aria-label="Create custom-domain router credentials">
<input type="hidden" name="mode" value="custom">
<input type="hidden" name="claim_secret" value="{claim_secret}">
<label>Verified hostname
<input class="form-control" name="hostname" placeholder="home.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required>
</label>
<p class="section-copy mb-0">Router fields are created after this step.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
</form>
</div>
</div>
"""
)
def _locked_custom_hostname_card() -> SafeHtml:
return trusted(
"""
<div class="col-lg-5">
<div class="card card-body h-100 gap-3 opacity-75">
<span class="badge text-bg-primary rounded-circle align-self-start">2</span>
<h3>Create hostname</h3>
<p class="small text-secondary mb-0 mt-2">Available after KarlDNS can see the TXT record.</p>
</div>
</div>
"""
)