Build KarlDNS self-hosted DynDNS service

This commit is contained in:
StasonJatham committed 2026-05-20 23:55:27 +02:00
1 parent 45bc38d950
commit ca92dc211a
65 files changed
+9200 -3570

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.concurrency import run_in_threadpool
from starlette.middleware.trustedhost import TrustedHostMiddleware
from .ddns_dns import DnsConfigurationError, DnsProviderError, delete_dns_records, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_service import DdnsService
from .ddns_store import DdnsStore
from .routers import admin, api, assets, management, public, updates
from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware
__all__ = [
"DdnsSettings",
"DdnsStore",
"DnsConfigurationError",
"DnsProviderError",
"UpdateResult",
"delete_dns_records",
"make_app",
"publish_dns",
]
OPENAPI_TAGS = [
{
"name": "hostnames",
"description": "Create provider-owned or verified custom DynDNS hostnames.",
},
{
"name": "domains",
"description": "Issue and verify DNS TXT challenges for custom domains.",
},
{
"name": "updates",
"description": "Router-facing IP update endpoints for FRITZ!Box and compatible clients.",
},
{
"name": "management",
"description": "Bearer-link JSON management for generated hostnames. Dashboard passwords protect only the web dashboard, not these API routes.",
},
]
CLEANUP_TASK_NAME = "cleanup"
RATE_LIMIT_FLUSH_INTERVAL_SECONDS = 10
LOGGER = logging.getLogger(__name__)
def make_app(settings: DdnsSettings | None = None) -> FastAPI:
settings = settings or DdnsSettings.from_env()
settings.validate_launch_ready()
store = DdnsStore(settings.database_path)
store.ensure_scheduled_task(CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
store.optimize()
service = DdnsService(settings, store)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
cleanup_task = asyncio.create_task(_scheduler_loop(store, settings))
rate_limit_task = asyncio.create_task(_rate_limit_flush_loop(store))
try:
yield
finally:
cleanup_task.cancel()
rate_limit_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await cleanup_task
with contextlib.suppress(asyncio.CancelledError):
await rate_limit_task
await run_in_threadpool(store.flush_rate_limits)
await run_in_threadpool(store.close_connections)
app = 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."
),
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc",
openapi_tags=OPENAPI_TAGS,
lifespan=lifespan,
)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.effective_trusted_hosts)
app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes)
app.state.settings = settings
app.state.store = store
app.state.service = service
install_security_middleware(app, settings, store)
app.include_router(api.router)
app.include_router(assets.router)
app.include_router(public.router)
app.include_router(management.router)
app.include_router(admin.make_router(settings.admin_path))
app.include_router(updates.router)
return app
async def _scheduler_loop(store: DdnsStore, settings: DdnsSettings) -> None:
poll_seconds = min(settings.cleanup_interval_seconds, 60)
while True:
await _run_cleanup_if_due(store, settings)
await asyncio.sleep(poll_seconds)
async def _rate_limit_flush_loop(store: DdnsStore) -> None:
while True:
await asyncio.sleep(RATE_LIMIT_FLUSH_INTERVAL_SECONDS)
await run_in_threadpool(store.flush_rate_limits)
await run_in_threadpool(store.prune_rate_limit_storage)
await run_in_threadpool(store.checkpoint_wal)
async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None:
lease_owner = f"{os.getpid()}:{id(store)}"
lease_seconds = max(60, settings.cleanup_interval_seconds)
acquired = await run_in_threadpool(
store.acquire_due_task,
CLEANUP_TASK_NAME,
lease_owner,
lease_seconds,
)
if not acquired:
return
try:
await run_in_threadpool(
store.cleanup,
settings.cleanup_challenge_hours,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
)
except Exception as exc:
LOGGER.exception("Scheduled cleanup failed")
await run_in_threadpool(
store.complete_scheduled_task,
CLEANUP_TASK_NAME,
settings.cleanup_interval_seconds,
False,
f"{type(exc).__name__}: {exc}",
)
return
await run_in_threadpool(
store.complete_scheduled_task,
CLEANUP_TASK_NAME,
settings.cleanup_interval_seconds,
True,
"",
)
await run_in_threadpool(store.optimize)
+6 -2
View File
@@ -17,6 +17,11 @@ def main() -> None:
serve = subparsers.add_parser("serve", help="Run the DDNS HTTP service.")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=8080)
serve.add_argument(
"--access-log",
action="store_true",
help="Enable Uvicorn access logs. Disabled by default because update URLs are bearer secrets.",
)
args = parser.parse_args()
if args.command == "render-fritz-url":
@@ -27,7 +32,7 @@ def main() -> None:
settings = DdnsSettings.from_env()
if not settings.admin_password:
raise SystemExit("DDNS_ADMIN_PASSWORD must be set.")
uvicorn.run(make_app(settings), host=args.host, port=args.port)
uvicorn.run(make_app(settings), host=args.host, port=args.port, access_log=args.access_log)
def _render_fritz_url(base_url: str, host: str, include_ipv6: bool) -> str:
@@ -40,4 +45,3 @@ def _render_fritz_url(base_url: str, host: str, include_ipv6: bool) -> str:
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import os
from pathlib import Path
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),
trusted_proxy_ips=_csv_set("DDNS_TRUSTED_PROXY_IPS"),
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
rfc2136_key_name=os.getenv("DDNS_RFC2136_KEY_NAME") or None,
rfc2136_key_secret=os.getenv("DDNS_RFC2136_KEY_SECRET") or None,
dns_provider=os.getenv("DDNS_DNS_PROVIDER", ""),
dns_zones=_csv_set("DDNS_DNS_ZONES"),
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),
dashboard_unlock_seconds=_env_int("DDNS_DASHBOARD_UNLOCK_SECONDS", 24 * 60 * 60),
dashboard_stay_unlocked_seconds=_env_int(
"DDNS_DASHBOARD_STAY_UNLOCKED_SECONDS",
30 * 24 * 60 * 60,
),
ttl=_env_int("DDNS_TTL", 60),
)
def _csv_set(name: str) -> set[str]:
return {item.strip().lower() for item in os.getenv(name, "").split(",") if item.strip()}
def _env_flag(name: str) -> bool:
return os.getenv(name, "0").strip().lower() not in {"0", "false", "no"}
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)).strip())
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
+4 -1552
View File
File diff suppressed because it is too large. Load diff
+25 -243
View File
@@ -1,245 +1,27 @@
import sqlite3
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from .schemas import (
CustomHostnameRequest,
DomainChallengeRequest,
DomainChallengeResponse,
ErrorResponse,
HostnameCredentials,
MagicHostnameRequest,
ManagedRecord,
RouterUpdateResponse,
VerifyDomainRequest,
VerifyDomainResponse,
)
from .ddns_models import DdnsSettings
from .ddns_service import DdnsService, normalize_domain
from .ddns_store import DdnsStore
class ErrorResponse(BaseModel):
detail: str = Field(examples=["not found"])
class MagicHostnameRequest(BaseModel):
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class HostnameCredentials(BaseModel):
hostname: str
username: str
password: str
update_url: str
credentialed_update_url: str
management_url: str
class ManagedRecord(BaseModel):
hostname: str
username: str
update_url: str
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
class DomainChallengeRequest(BaseModel):
domain: str = Field(examples=["example.net"])
class DomainChallengeResponse(BaseModel):
domain: str
txt_name: str
txt_value: str
claim_secret: str
class VerifyDomainRequest(BaseModel):
domain: str = Field(examples=["example.net"])
claim_secret: str
class VerifyDomainResponse(BaseModel):
domain: str
verified: bool
message: str
claim_secret: str
class CustomHostnameRequest(BaseModel):
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
ip: str
def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsService) -> APIRouter:
router = APIRouter(prefix="/api/v1")
@router.post(
"/hostnames/magic",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate a provider-owned DynDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_magic_hostname(payload: MagicHostnameRequest) -> HostnameCredentials:
account = await run_in_threadpool(service.create_managed_account, payload.username)
return _credentials_response(service, account)
@router.get(
"/management/{management_slug}",
response_model=ManagedRecord,
summary="Read a hostname by magic management link",
tags=["management"],
responses={404: {"model": ErrorResponse}},
)
async def read_managed_hostname(management_slug: str) -> ManagedRecord:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return _managed_record(service, account)
@router.delete(
"/management/{management_slug}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by magic management link",
tags=["management"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_managed_hostname(management_slug: str) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "api")
except Exception as exc:
raise HTTPException(status_code=500, detail="DNS delete failed; hostname was not deleted") from exc
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,
) -> 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,
) -> 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="Generate credentials for a verified custom hostname",
tags=["hostnames"],
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def create_custom_hostname(
payload: CustomHostnameRequest,
) -> 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,
summary="Router-compatible update endpoint",
tags=["updates"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def update_hostname(
update_slug: str,
request: Request,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RouterUpdateResponse:
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
outcome = await run_in_threadpool(
service.apply_update,
request,
hostname,
myip,
ipaddr,
myipv6,
ip6addr,
)
except Exception as exc:
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
result = outcome.result
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
return router
def _credentials_response(service: DdnsService, account: dict[str, str]) -> HostnameCredentials:
return HostnameCredentials(
hostname=account["hostname"],
username=account["username"],
password=account["password"],
update_url=service.fritz_update_url(account),
credentialed_update_url=service.credentialed_update_url(account),
management_url=service.magic_management_url(account),
)
def _managed_record(service: DdnsService, account: dict[str, str | int | None]) -> ManagedRecord:
account_for_url = {"update_slug": str(account["update_slug"])}
return ManagedRecord(
hostname=str(account["hostname"]),
username=str(account["username"]),
update_url=service.fritz_update_url(account_for_url),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
)
__all__ = [
"CustomHostnameRequest",
"DomainChallengeRequest",
"DomainChallengeResponse",
"ErrorResponse",
"HostnameCredentials",
"MagicHostnameRequest",
"ManagedRecord",
"RouterUpdateResponse",
"VerifyDomainRequest",
"VerifyDomainResponse",
]
+24 -152
View File
@@ -1,155 +1,27 @@
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from .dns import (
DnsConfigurationError,
DnsProviderError,
delete_cloudflare_records,
delete_dns_records,
delete_rfc2136_records,
dns_backend_configured,
publish_cloudflare,
publish_dns,
publish_rfc2136,
validate_dns_provider_settings,
)
from .ddns_models import DdnsSettings, UpdateResult
def publish_dns(result: UpdateResult, settings: DdnsSettings) -> None:
provider = settings.dns_provider.strip().lower()
if provider == "cloudflare":
publish_cloudflare(result, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
publish_rfc2136(result, settings)
return
if settings.require_dns_provider:
raise RuntimeError("DNS provider is required but not configured")
def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
provider = settings.dns_provider.strip().lower()
if provider == "cloudflare":
delete_cloudflare_records(hostname, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
delete_rfc2136_records(hostname, settings)
return
if settings.require_dns_provider:
raise RuntimeError("DNS provider is required but not configured")
def dns_backend_configured(settings: DdnsSettings) -> bool:
provider = settings.dns_provider.strip().lower()
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.query
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(result.hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = result.hostname.removesuffix("." + zone).rstrip(".") or "@"
if result.update_ipv4 and result.ipv4:
update.replace(relative, settings.ttl, "A", result.ipv4)
elif result.update_ipv4:
update.delete(relative, "A")
if result.update_ipv6 and result.ipv6:
update.replace(relative, settings.ttl, "AAAA", result.ipv6)
elif result.update_ipv6:
update.delete(relative, "AAAA")
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
rcode = response.rcode()
if rcode != 0:
import dns.rcode
raise RuntimeError(f"RFC2136 update failed: {dns.rcode.to_text(rcode)}")
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.query
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = hostname.removesuffix("." + zone).rstrip(".") or "@"
update.delete(relative, "A")
update.delete(relative, "AAAA")
dns.query.tcp(update, settings.rfc2136_server, timeout=5)
def publish_cloudflare(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise RuntimeError("DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required")
if result.update_ipv4:
_cloudflare_upsert_record(settings, result.hostname, "A", result.ipv4)
if result.update_ipv6:
_cloudflare_upsert_record(settings, result.hostname, "AAAA", result.ipv6)
def delete_cloudflare_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise RuntimeError("DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required")
for record_type in ("A", "AAAA"):
record = _cloudflare_find_record(settings, hostname, record_type)
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
def _cloudflare_upsert_record(settings: DdnsSettings, hostname: str, record_type: str, value: str | None) -> None:
record = _cloudflare_find_record(settings, hostname, record_type)
if value is None:
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
return
payload = {
"type": record_type,
"name": hostname,
"content": value,
"ttl": settings.ttl,
"proxied": False,
}
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="PATCH", payload=payload)
else:
_cloudflare_request(settings, "/dns_records", method="POST", payload=payload)
def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, str] | None:
query = urllib.parse.urlencode({"type": record_type, "name": hostname})
response = _cloudflare_request(settings, f"/dns_records?{query}")
records = response.get("result", [])
return records[0] if records else None
def _cloudflare_request(
settings: DdnsSettings,
path: str,
method: str = "GET",
payload: dict[str, object] | None = None,
) -> dict[str, object]:
url = f"https://api.cloudflare.com/client/v4/zones/{settings.cloudflare_zone_id}{path}"
body = json.dumps(payload).encode("utf-8") if payload is not None else None
request = urllib.request.Request(url, data=body, method=method)
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=10) as response:
parsed = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Cloudflare API {exc.code}: {detail}") from exc
if not parsed.get("success", False):
raise RuntimeError(f"Cloudflare API error: {parsed}")
return parsed
__all__ = [
"DnsConfigurationError",
"DnsProviderError",
"delete_cloudflare_records",
"delete_dns_records",
"delete_rfc2136_records",
"dns_backend_configured",
"publish_cloudflare",
"publish_dns",
"publish_rfc2136",
"validate_dns_provider_settings",
]
+46 -47
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
import os
import re
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
@@ -12,6 +12,7 @@ class DdnsSettings(BaseModel):
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)
@@ -32,7 +33,10 @@ class DdnsSettings(BaseModel):
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)
dashboard_unlock_seconds: int = Field(default=24 * 60 * 60, ge=60, le=30 * 24 * 60 * 60)
dashboard_stay_unlocked_seconds: int = Field(default=30 * 24 * 60 * 60, ge=60, le=30 * 24 * 60 * 60)
ttl: int = Field(default=60, ge=60, le=86400)
@field_validator("public_base_url")
@@ -47,6 +51,41 @@ class DdnsSettings(BaseModel):
def validate_trusted_hosts(cls, value: set[str]) -> set[str]:
return {host.strip().lower() for host in value if host.strip()}
@field_validator("admin_path")
@classmethod
def validate_admin_path(cls, value: str) -> str:
path = value.strip()
if not path.startswith("/") or path == "/" or path.endswith("/"):
raise ValueError("admin_path must start with / and must not be / or end with /")
if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path):
raise ValueError("admin_path may only contain URL-safe path characters")
reserved_paths = {
"/accounts",
"/api",
"/app.js",
"/claim",
"/d",
"/docs",
"/favicon.ico",
"/healthz",
"/install.sh",
"/ip",
"/logo.png",
"/m",
"/magic",
"/nic",
"/openapi.json",
"/privacy",
"/redoc",
"/robots.txt",
"/site.webmanifest",
"/u",
"/uninstall.sh",
}
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
raise ValueError("admin_path must not conflict with a public route")
return path
@property
def effective_trusted_hosts(self) -> list[str]:
hosts = set(self.trusted_hosts)
@@ -58,6 +97,9 @@ class DdnsSettings(BaseModel):
return sorted(hosts)
def validate_launch_ready(self) -> None:
from .dns import validate_dns_provider_settings
validate_dns_provider_settings(self)
if not self.require_dns_provider:
return
if len(self.admin_password) < 16:
@@ -69,41 +111,9 @@ class DdnsSettings(BaseModel):
@classmethod
def from_env(cls) -> DdnsSettings:
allowed = {
item.strip().lower()
for item in os.getenv("DDNS_ALLOWED_HOSTS", "").split(",")
if item.strip()
}
trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS")
trusted_hosts = _csv_set("DDNS_TRUSTED_HOSTS")
dns_zones = _csv_set("DDNS_DNS_ZONES")
return cls(
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
shared_secret=os.getenv("DDNS_SHARED_SECRET", ""),
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", os.getenv("DDNS_SHARED_SECRET", "")),
allowed_hosts=allowed,
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
trusted_hosts=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),
trusted_proxy_ips=trusted_proxy_ips,
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
rfc2136_key_name=os.getenv("DDNS_RFC2136_KEY_NAME") or None,
rfc2136_key_secret=os.getenv("DDNS_RFC2136_KEY_SECRET") or None,
dns_provider=os.getenv("DDNS_DNS_PROVIDER", ""),
dns_zones=dns_zones,
require_dns_provider=os.getenv("DDNS_REQUIRE_DNS_PROVIDER", "0").strip().lower() not in {"0", "false", "no"},
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_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
ttl=_env_int("DDNS_TTL", 60),
)
from .config import settings_from_env
return settings_from_env()
@dataclass(slots=True)
@@ -119,14 +129,3 @@ class UpdateResult:
def dyndns_response(self) -> str:
ip = self.ipv4 or self.ipv6 or "0.0.0.0"
return f"{'good' if self.changed else 'nochg'} {ip}"
def _csv_set(name: str) -> set[str]:
return {item.strip().lower() for item in os.getenv(name, "").split(",") if item.strip()}
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except ValueError:
return default
+66 -3
View File
@@ -4,6 +4,7 @@ import base64
import hashlib
import hmac
import secrets
import time
from datetime import UTC, datetime
from ipaddress import ip_address, ip_network
@@ -11,6 +12,10 @@ from fastapi import HTTPException, Request
from .ddns_models import DdnsSettings
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
try:
@@ -44,10 +49,68 @@ def hash_lookup_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def admin_csrf_token(settings: DdnsSettings) -> str:
secret = settings.admin_password or settings.shared_secret
def sign_dashboard_unlock_cookie(
hostname: str,
management_slug: str,
password_hash: str,
max_age_seconds: int,
now: int | None = None,
) -> str:
timestamp = int(time.time() if now is None else now)
bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds)
signature = _dashboard_unlock_signature(hostname, management_slug, password_hash, timestamp, bounded_max_age)
return f"v2:{timestamp}:{bounded_max_age}:{signature}"
def verify_dashboard_unlock_cookie(hostname: str, management_slug: str, password_hash: str | None, cookie_value: str | None) -> bool:
if not password_hash or not cookie_value:
return False
try:
version, raw_timestamp, raw_max_age, supplied = cookie_value.split(":", 3)
timestamp = int(raw_timestamp)
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
except ValueError:
return False
if version != "v2":
return False
now = int(time.time())
if timestamp > now + 60 or now - timestamp > max_age_seconds:
return False
expected = _dashboard_unlock_signature(hostname, management_slug, password_hash, timestamp, max_age_seconds)
return hmac.compare_digest(supplied, expected)
def _dashboard_unlock_signature(
hostname: str,
management_slug: str,
password_hash: str,
timestamp: int,
max_age_seconds: int,
) -> str:
payload = f"{hostname}\0{management_slug}\0{timestamp}\0{max_age_seconds}".encode()
return hmac.new(password_hash.encode(), payload, hashlib.sha256).hexdigest()
def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | None]) -> str:
hostname = str(account["hostname"])
password_hash = str(account.get("dashboard_password_hash") or "")
day = datetime.now(UTC).strftime("%Y-%m-%d")
return hmac.new(secret.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest()
key = f"{management_slug}\0{password_hash}".encode()
return hmac.new(key, f"dashboard:{hostname}:{day}".encode(), hashlib.sha256).hexdigest()
def require_dashboard_csrf(management_slug: str, account: dict[str, str | int | None], supplied: str) -> None:
if not supplied or not hmac.compare_digest(supplied, dashboard_csrf_token(management_slug, account)):
raise HTTPException(status_code=403, detail="invalid csrf token")
def _bounded_dashboard_unlock_seconds(max_age_seconds: int) -> int:
return max(60, min(max_age_seconds, DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS))
def admin_csrf_token(settings: DdnsSettings) -> str:
day = datetime.now(UTC).strftime("%Y-%m-%d")
return hmac.new(settings.admin_password.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest()
def require_admin_csrf(settings: DdnsSettings, supplied: str) -> None:
+91 -40
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import ipaddress
import logging
import re
import secrets
import sqlite3
@@ -10,10 +11,13 @@ from dataclasses import dataclass
from fastapi import HTTPException, Request
from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns
from .ddns_dns import DnsProviderError, delete_dns_records, dns_backend_configured, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_security import client_ip
from .ddns_store import DdnsStore
from .event_statuses import DNS_DELETE_FAILED, DNS_FAILED, UNCHANGED, UPDATED
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
@@ -22,12 +26,17 @@ class UpdateOutcome:
source_ip: str
class BadIpError(ValueError):
"""Raised when a supplied DynDNS IP parameter is invalid."""
class DdnsService:
_LOCK_STRIPES = 256
def __init__(self, settings: DdnsSettings, store: DdnsStore) -> None:
self.settings = settings
self.store = store
self._locks_guard = threading.Lock()
self._locks: dict[str, threading.Lock] = {}
self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
def create_managed_account(self, username: str | None = None) -> dict[str, str]:
if not self.settings.hostname_suffix:
@@ -44,33 +53,39 @@ class DdnsService:
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):
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) -> dict[str, str]:
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)
return self.store.create_domain_challenge(normalized, owner_hostname=owner_hostname)
def verify_domain(self, domain: str, claim_secret: str) -> tuple[bool, dict[str, str | None]]:
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)
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"]))
@@ -78,18 +93,22 @@ class DdnsService:
challenge = dict(challenge)
challenge["claim_secret"] = claim_secret
return False, challenge
verified = self.store.verify_domain_challenge(normalized)
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")
verified = dict(verified)
verified["claim_secret"] = claim_secret
return True, verified
def delete_account(self, hostname: str, source: str) -> None:
try:
delete_dns_records(hostname, self.settings)
except Exception as exc:
self.store.log_update_event(hostname, None, None, "dns_delete_failed", f"{type(exc).__name__}: {exc}", source)
except DnsProviderError as exc:
LOGGER.warning(
"DNS delete failed for hostname=%s source=%s error_type=%s",
hostname,
source,
type(exc).__name__,
)
self.store.log_update_event(hostname, None, None, DNS_DELETE_FAILED, f"{type(exc).__name__}: {exc}", source)
raise
self.store.delete_account(hostname)
@@ -102,19 +121,35 @@ class DdnsService:
myipv6: str | None,
ip6addr: str | None,
) -> UpdateOutcome:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(request, myip, ipaddr, myipv6, ip6addr)
source_ip = client_ip(request, self.settings)
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_ip,
myip,
ipaddr,
myipv6,
ip6addr,
)
lock = self._lock_for(hostname)
with lock:
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
if planned.changed:
try:
publish_dns(planned, self.settings)
except Exception as exc:
self.store.log_update_event(hostname, ipv4, ipv6, "dns_failed", f"{type(exc).__name__}: {exc}", source_ip)
except DnsProviderError as exc:
LOGGER.warning(
"DNS publish failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
hostname,
planned.update_ipv4,
planned.update_ipv6,
type(exc).__name__,
)
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
raise
result = self.store.upsert(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, "updated" if result.changed else "unchanged", "ok", source_ip)
result = self.store.commit_update_result(planned)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "ok", source_ip)
else:
result = planned
self.store.log_update_rollup(hostname, result.ipv4, result.ipv6, UNCHANGED, "ok", source_ip)
return UpdateOutcome(result=result, source_ip=source_ip)
def fritz_update_url(self, account: dict[str, str]) -> str:
@@ -149,13 +184,18 @@ class DdnsService:
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) -> str | None:
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):
if self.store.is_domain_verified(candidate, claim_secret, owner_hostname=owner_hostname):
return candidate
if self.store.is_domain_verified(hostname, claim_secret):
if self.store.is_domain_verified(hostname, claim_secret, owner_hostname=owner_hostname):
return hostname
return None
@@ -178,19 +218,14 @@ class DdnsService:
return bool(zones) and any(hostname == zone or hostname.endswith(f".{zone}") for zone in zones)
def _lock_for(self, hostname: str) -> threading.Lock:
with self._locks_guard:
lock = self._locks.get(hostname)
if lock is None:
lock = threading.Lock()
self._locks[hostname] = lock
return lock
return self._locks[hash(hostname) % self._LOCK_STRIPES]
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
if not value:
if not value or not value.strip():
return None
try:
ip = ipaddress.ip_address(value)
ip = ipaddress.ip_address(value.strip())
except ValueError:
return None
if version and ip.version != version:
@@ -199,27 +234,42 @@ def normalize_ip(value: str | None, version: int | None = None) -> str | None:
def parse_update_ips(
request: Request,
source_ip: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> tuple[str | None, str | None, bool, bool]:
raw_ipv4 = myip if myip is not None else ipaddr
raw_ipv6 = myipv6 if myipv6 is not None else ip6addr
update_ipv4 = raw_ipv4 is not None
update_ipv6 = raw_ipv6 is not None
ipv4 = normalize_ip(raw_ipv4, version=4)
ipv6 = normalize_ip(raw_ipv6, version=6)
ipv4 = _first_valid_ip((("myip", myip), ("ipaddr", ipaddr)), version=4)
ipv6 = _first_valid_ip((("myipv6", myipv6), ("ip6addr", ip6addr)), version=6)
update_ipv4 = ipv4 is not None
update_ipv6 = ipv6 is not None
if not update_ipv4 and not update_ipv6:
peer = request.client.host if request.client else None
ipv4 = normalize_ip(peer, version=4)
ipv6 = normalize_ip(peer, version=6)
ipv4 = normalize_ip(source_ip, version=4)
ipv6 = normalize_ip(source_ip, version=6)
update_ipv4 = ipv4 is not None
update_ipv6 = ipv6 is not None
if not update_ipv4 and not update_ipv6:
raise BadIpError("source IP is not a valid IPv4 or IPv6 address")
return ipv4, ipv6, update_ipv4, update_ipv6
def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) -> str | None:
selected = None
for name, value in params:
if value is None or not value.strip():
continue
try:
ip = ipaddress.ip_address(value.strip())
except ValueError as exc:
raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
if ip.version != version:
raise BadIpError(f"{name} must be an IPv{version} address")
if selected is None:
selected = str(ip)
return selected
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
hostname = value.strip().lower().rstrip(".")
suffix = settings.hostname_suffix.strip().lower().strip(".")
@@ -237,10 +287,11 @@ def normalize_domain(value: str) -> str:
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 Exception:
except dns.exception.DNSException:
return False
for answer in answers:
for item in answer.strings:
+2 -509
View File
@@ -1,512 +1,5 @@
from __future__ import annotations
import secrets
import sqlite3
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from .store import DdnsStore
from .ddns_models import UpdateResult
from .ddns_security import hash_lookup_token, hash_secret, verify_secret
class DdnsStore:
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.database_path)
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 5000")
return conn
def upsert(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(hostname, effective_ipv4, effective_ipv6, now),
)
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def preview_update(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def create_account(self, hostname: str, username: str | None = None) -> dict[str, str]:
now = datetime.now(UTC).isoformat()
token = secrets.token_urlsafe(24)
update_slug = secrets.token_urlsafe(32)
management_slug = secrets.token_urlsafe(32)
username = username or _default_username(hostname)
token_hash = hash_secret(token)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute(
"""
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
VALUES (?, ?, ?, ?, ?, ?, 0)
""",
(hostname, username, token_hash, update_slug, management_hash, now),
)
return {
"hostname": hostname,
"username": username,
"password": token,
"update_slug": update_slug,
"management_slug": management_slug,
}
def list_accounts(self) -> list[dict[str, str | int | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname
"""
).fetchall()
return [dict(row) for row in rows]
def get_account_by_hostname(self, hostname: str) -> dict[str, str | int | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
""",
(hostname,),
).fetchone()
return dict(account) if account else None
def get_account_by_slug(self, update_slug: str) -> dict[str, str | int | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
WHERE update_slug = ?
""",
(update_slug,),
).fetchone()
return dict(account) if account else None
def get_account_by_management_slug(self, management_slug: str) -> dict[str, str | int | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ?
""",
(hash_lookup_token(management_slug),),
).fetchone()
return dict(account) if account else None
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool:
if not username or not token:
return False
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT token_hash, disabled FROM accounts
WHERE hostname = ? AND username = ?
""",
(hostname, username),
).fetchone()
if not account or account["disabled"]:
return False
return verify_secret(token, account["token_hash"])
def delete_account(self, hostname: str) -> None:
with self.connect() as conn:
conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,))
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
def rotate_update_slug(self, hostname: str) -> dict[str, str | int | None] | None:
update_slug = secrets.token_urlsafe(32)
with self.connect() as conn:
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
return self.get_account_by_hostname(hostname)
def rotate_management_slug(self, hostname: str) -> dict[str, str | int | None] | None:
management_slug = secrets.token_urlsafe(32)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["management_slug"] = management_slug
return account
def rotate_password(self, hostname: str) -> dict[str, str | int | None] | None:
token = secrets.token_urlsafe(24)
token_hash = hash_secret(token)
with self.connect() as conn:
conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["password"] = token
return account
def create_domain_challenge(self, domain: str) -> dict[str, str]:
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 FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
if existing and existing[0]:
raise sqlite3.IntegrityError("domain is already verified")
conn.execute(
"""
INSERT INTO domain_challenges(domain, token, claim_hash, created_at, verified_at)
VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(domain) DO UPDATE SET
token=excluded.token,
claim_hash=excluded.claim_hash,
created_at=excluded.created_at,
verified_at=NULL
""",
(domain, token, claim_hash, now),
)
return {"domain": domain, "token": token, "claim_secret": claim_secret}
def verify_domain_challenge(self, domain: str) -> dict[str, str | None] | None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.row_factory = sqlite3.Row
challenge = conn.execute("SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
if not challenge:
return None
conn.execute("UPDATE domain_challenges SET verified_at = ? WHERE domain = ?", (now, domain))
result = dict(challenge)
result["verified_at"] = now
return result
def get_domain_challenge(self, domain: str, claim_secret: str | None = None) -> dict[str, str | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
sql = "SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?"
params = (domain,)
if claim_secret is not None:
sql += " AND claim_hash = ?"
params = (domain, hash_lookup_token(claim_secret))
challenge = conn.execute(sql, params).fetchone()
return dict(challenge) if challenge else None
def get_domain_challenge_by_secret(self, claim_secret: str) -> dict[str, str | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
challenge = conn.execute(
"SELECT domain, token, 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) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT domain, created_at, verified_at
FROM domain_challenges
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def is_domain_verified(self, domain: str, claim_secret: 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 = (domain, hash_lookup_token(claim_secret))
verified_at = conn.execute(sql, params).fetchone()
return bool(verified_at and verified_at[0])
def list_records(self) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT hostname, ipv4, ipv6, updated_at FROM records ORDER BY hostname").fetchall()
return [dict(row) for row in rows]
def log_update_event(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()),
)
def list_update_events(self, limit: int = 100) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at
FROM update_events
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def list_update_events_for_hostname(self, hostname: str, limit: int = 25) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at
FROM update_events
WHERE hostname = ?
ORDER BY created_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
return [dict(row) for row in rows]
def list_cleanup_runs(self, limit: int = 20) -> list[dict[str, str | int]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
FROM cleanup_runs
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def allow_rate_limit(self, key: str, limit: int) -> bool:
now = int(time.time())
window = now // 60
with self.connect() as conn:
conn.execute("DELETE FROM rate_limits WHERE window < ?", (window - 2,))
row = conn.execute(
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
(key, window),
).fetchone()
count = int(row[0]) if row else 0
if count >= limit:
return False
conn.execute(
"""
INSERT INTO rate_limits(key, window, count)
VALUES (?, ?, 1)
ON CONFLICT(key, window) DO UPDATE SET count=count + 1
""",
(key, window),
)
return True
def cleanup(self, challenge_hours: int, unused_account_hours: int) -> dict[str, int]:
challenge_cutoff = (datetime.now(UTC) - timedelta(hours=challenge_hours)).isoformat()
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_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
WHERE created_at < ?
AND NOT EXISTS (
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
)
""",
(unused_account_cutoff,),
)
conn.execute("DELETE FROM rate_limits WHERE window < ?", (int(time.time()) - 3600,))
conn.execute(
"""
INSERT INTO cleanup_runs(created_at, domain_challenges_deleted, unused_accounts_deleted)
VALUES (?, ?, ?)
""",
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
)
return {
"domain_challenges": challenge_cursor.rowcount,
"unused_accounts": account_cursor.rowcount,
}
def _init_db(self) -> None:
with self.connect() as conn:
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS records (
hostname TEXT PRIMARY KEY,
ipv4 TEXT,
ipv6 TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ipv4 TEXT,
ipv6 TEXT,
status TEXT NOT NULL,
detail TEXT NOT NULL,
source_ip TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
conn.execute(
"""
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
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
window INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY(key, window)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE,
created_at TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
_ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT")
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (hash_lookup_token(secrets.token_urlsafe(32)), row[0]))
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS domain_challenges (
domain TEXT PRIMARY KEY,
token TEXT NOT NULL,
claim_hash TEXT,
created_at TEXT NOT NULL,
verified_at TEXT
)
"""
)
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
def _default_username(hostname: str) -> str:
label = hostname.split(".", 1)[0]
import re
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
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}")
__all__ = ["DdnsStore"]
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import secrets
from typing import Annotated
from fastapi import Depends, Header, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from .ddns_dns import DnsProviderError
from .ddns_models import DdnsSettings
from .ddns_security import credentials_from_basic_auth
from .ddns_service import BadIpError, DdnsService
from .ddns_store import DdnsStore
def get_settings(request: Request) -> DdnsSettings:
return request.app.state.settings
def get_store(request: Request) -> DdnsStore:
return request.app.state.store
def get_service(request: Request) -> DdnsService:
return request.app.state.service
SettingsDep = Annotated[DdnsSettings, Depends(get_settings)]
StoreDep = Annotated[DdnsStore, Depends(get_store)]
ServiceDep = Annotated[DdnsService, Depends(get_service)]
def authenticate_update(
authorization: Annotated[str | None, Header()] = None,
username: str | None = Query(default=None),
password: str | None = Query(default=None),
pass_: str | None = Query(default=None, alias="pass"),
passwd: str | None = Query(default=None),
) -> tuple[str | None, str | None]:
supplied_user = username
supplied_token = password or pass_ or passwd
if authorization and authorization.lower().startswith("basic "):
supplied_user, supplied_token = credentials_from_basic_auth(authorization)
return supplied_user, supplied_token
def authenticate_admin(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> None:
settings = get_settings(request)
if not settings.admin_password:
raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required")
supplied = None
if authorization and authorization.lower().startswith("basic "):
_, supplied = credentials_from_basic_auth(authorization)
if not supplied or not secrets.compare_digest(supplied, settings.admin_password):
raise HTTPException(
status_code=401,
detail="admin authentication required",
headers={"WWW-Authenticate": 'Basic realm="karldns"'},
)
def apply_update_response(
service: DdnsService,
request: Request,
hostname: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> str | PlainTextResponse:
try:
return service.apply_update(request, hostname, myip, ipaddr, myipv6, ip6addr).result.dyndns_response
except BadIpError:
return PlainTextResponse("badip", status_code=400)
except DnsProviderError:
return PlainTextResponse("911 dns publish failed", status_code=500)
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from .base import (
DnsConfigurationError,
DnsProviderError,
delete_dns_records,
dns_backend_configured,
publish_dns,
validate_dns_provider_settings,
)
from .cloudflare import delete_cloudflare_records, publish_cloudflare
from .rfc2136 import delete_rfc2136_records, publish_rfc2136
__all__ = [
"DnsConfigurationError",
"DnsProviderError",
"delete_cloudflare_records",
"delete_dns_records",
"delete_rfc2136_records",
"dns_backend_configured",
"publish_cloudflare",
"publish_dns",
"publish_rfc2136",
"validate_dns_provider_settings",
]
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings, UpdateResult
SUPPORTED_DNS_PROVIDERS = {"", "cloudflare", "rfc2136"}
class DnsProviderError(RuntimeError):
"""Raised for expected DNS provider publish/delete failures."""
class DnsConfigurationError(RuntimeError):
"""Raised when DNS provider settings are invalid at startup."""
def publish_dns(result: UpdateResult, settings: DdnsSettings) -> None:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
if provider == "cloudflare":
from .cloudflare import publish_cloudflare
publish_cloudflare(result, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
from .rfc2136 import publish_rfc2136
publish_rfc2136(result, settings)
return
if settings.require_dns_provider:
raise DnsProviderError("DNS provider is required but not configured")
def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
if provider == "cloudflare":
from .cloudflare import delete_cloudflare_records
delete_cloudflare_records(hostname, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
from .rfc2136 import delete_rfc2136_records
delete_rfc2136_records(hostname, settings)
return
if settings.require_dns_provider:
raise DnsProviderError("DNS provider is required but not configured")
def dns_backend_configured(settings: DdnsSettings) -> bool:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
def validate_dns_provider_settings(settings: DdnsSettings) -> None:
provider = _normalized_provider(settings)
if provider not in SUPPORTED_DNS_PROVIDERS:
raise DnsConfigurationError(f"Unsupported DDNS_DNS_PROVIDER: {settings.dns_provider}")
if not settings.require_dns_provider:
return
if provider == "cloudflare":
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsConfigurationError(
"DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
)
return
if provider == "rfc2136" or settings.rfc2136_server:
if not settings.rfc2136_server or not settings.rfc2136_zone:
raise DnsConfigurationError(
"DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
)
return
raise DnsConfigurationError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
def _normalized_provider(settings: DdnsSettings) -> str:
return settings.dns_provider.strip().lower()
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from ..ddns_models import DdnsSettings, UpdateResult
from .base import DnsProviderError
def publish_cloudflare(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsProviderError("Cloudflare DNS provider is missing required credentials")
if result.update_ipv4:
_cloudflare_upsert_record(settings, result.hostname, "A", result.ipv4)
if result.update_ipv6:
_cloudflare_upsert_record(settings, result.hostname, "AAAA", result.ipv6)
def delete_cloudflare_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsProviderError("Cloudflare DNS provider is missing required credentials")
for record_type in ("A", "AAAA"):
record = _cloudflare_find_record(settings, hostname, record_type)
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
def _cloudflare_upsert_record(settings: DdnsSettings, hostname: str, record_type: str, value: str | None) -> None:
record = _cloudflare_find_record(settings, hostname, record_type)
if value is None:
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
return
payload = {
"type": record_type,
"name": hostname,
"content": value,
"ttl": settings.ttl,
"proxied": False,
}
if record:
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="PATCH", payload=payload)
else:
_cloudflare_request(settings, "/dns_records", method="POST", payload=payload)
def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, str] | None:
query = urllib.parse.urlencode({"type": record_type, "name": hostname})
response = _cloudflare_request(settings, f"/dns_records?{query}")
records = response.get("result", [])
return records[0] if records else None
def _cloudflare_request(
settings: DdnsSettings,
path: str,
method: str = "GET",
payload: dict[str, object] | None = None,
) -> dict[str, object]:
url = f"https://api.cloudflare.com/client/v4/zones/{settings.cloudflare_zone_id}{path}"
body = json.dumps(payload).encode("utf-8") if payload is not None else None
request = urllib.request.Request(url, data=body, method=method)
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=10) as response:
parsed = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise DnsProviderError("Cloudflare API request failed") from exc
except json.JSONDecodeError as exc:
raise DnsProviderError("Cloudflare API returned invalid JSON") from exc
if not parsed.get("success", False):
raise DnsProviderError("Cloudflare API returned an unsuccessful response")
return parsed
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings, UpdateResult
from .base import DnsProviderError
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.exception
import dns.query
import dns.rcode
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(result.hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = result.hostname.removesuffix("." + zone).rstrip(".") or "@"
if result.update_ipv4 and result.ipv4:
update.replace(relative, settings.ttl, "A", result.ipv4)
elif result.update_ipv4:
update.delete(relative, "A")
if result.update_ipv6 and result.ipv6:
update.replace(relative, settings.ttl, "AAAA", result.ipv6)
elif result.update_ipv6:
update.delete(relative, "AAAA")
try:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
except dns.exception.DNSException as exc:
raise DnsProviderError("RFC2136 DNS update failed") from exc
rcode = response.rcode()
if rcode != 0:
raise DnsProviderError(f"RFC2136 update failed: {dns.rcode.to_text(rcode)}")
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.exception
import dns.query
import dns.rcode
import dns.tsigkeyring
import dns.update
keyring = None
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
zone = settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
update = dns.update.Update(zone, keyring=keyring)
relative = hostname.removesuffix("." + zone).rstrip(".") or "@"
update.delete(relative, "A")
update.delete(relative, "AAAA")
try:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
except dns.exception.DNSException as exc:
raise DnsProviderError("RFC2136 DNS delete failed") from exc
rcode = response.rcode()
if rcode != 0:
raise DnsProviderError(f"RFC2136 delete failed: {dns.rcode.to_text(rcode)}")
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
DNS_DELETE_FAILED = "dns_delete_failed"
DNS_FAILED = "dns_failed"
UPDATED = "updated"
UNCHANGED = "unchanged"
DASHBOARD_PASSWORD_CHANGED = "dashboard_password_changed"
DASHBOARD_PASSWORD_ENABLED = "dashboard_password_enabled"
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"
ADMIN_MANAGEMENT_URL_ROTATED = "admin_management_url_rotated"
ADMIN_ROUTER_CREDENTIALS_ROTATED = "admin_router_credentials_rotated"
View File
Whitespace-only changes.
+235
View File
@@ -0,0 +1,235 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_security import client_ip, require_admin_csrf
from ..ddns_service import normalize_hostname
from ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
from ..event_statuses import (
ADMIN_HOSTNAME_DELETED,
ADMIN_MANAGEMENT_URL_ROTATED,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
ADMIN_UPDATE_URL_ROTATED,
)
from ..web.forms import first_form_value, request_form
from ..web.templates import _copy_row
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100
def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter()
records_path = f"{admin_path}/records"
events_path = f"{admin_path}/events"
def render_admin(
service: ServiceDep,
settings: SettingsDep,
store: StoreDep,
table_offsets: dict[str, int] | None = None,
notice: str | None = None,
) -> str:
table_offsets = table_offsets or {}
return _render_admin_page(
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)),
{
"accounts": store.count_accounts(),
"domain_challenges": store.count_domain_challenges(),
"cleanup_runs": store.count_cleanup_runs(),
"update_events": store.count_update_events(),
},
table_offsets,
ADMIN_TABLE_LIMIT,
notice,
)
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
def admin(
settings: SettingsDep,
store: StoreDep,
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:
return render_admin(
service,
settings,
store,
{
"accounts": accounts_offset,
"domain_challenges": domain_claims_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
},
)
@router.post(f"{admin_path}/accounts/delete", include_in_schema=False)
async def admin_delete_account(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
hostname = normalize_hostname(first_form_value(form, "hostname"), settings, expand_suffix=False)
if hostname:
try:
await run_in_threadpool(service.delete_account, hostname, "admin")
except DnsProviderError:
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_HOSTNAME_DELETED,
"hostname deleted by admin",
client_ip(request, settings),
)
return RedirectResponse(admin_path, status_code=303)
@router.post(f"{admin_path}/accounts/rotate", include_in_schema=False)
async def admin_rotate_account(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
hostname = normalize_hostname(first_form_value(form, "hostname"), settings, expand_suffix=False)
action = first_form_value(form, "action")
account = await run_in_threadpool(store.get_account_by_hostname, hostname)
if not account:
raise HTTPException(status_code=404, detail="hostname not found")
notice = None
if action == "update":
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_UPDATE_URL_ROTATED,
"router update URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
elif action == "management":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_MANAGEMENT_URL_ROTATED,
"management URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Private status page rotated", [_copy_row("Private status page:", service.magic_management_url(rotated))]) if rotated else None
elif action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
"router credentials rotated by admin",
client_ip(request, settings),
)
if rotated:
notice = _admin_secret_panel(
"Router password rotated",
[
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
_copy_row("One-box update URL:", service.credentialed_update_url(rotated), "Secret"),
_copy_row("Benutzername (optional):", str(rotated["username"]), "Public"),
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
],
)
else:
raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
return HTMLResponse(page)
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
async def admin_run_cleanup(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
result = await run_in_threadpool(
store.cleanup,
settings.cleanup_challenge_hours,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
)
notice = _admin_secret_panel(
"Cleanup completed",
[
_copy_row("Domain claims removed", str(result["domain_challenges"])),
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
_copy_row("Old update events removed", str(result["update_events"])),
],
)
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
return HTMLResponse(page)
@router.get(records_path, include_in_schema=False)
def records(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict[str, object]:
return {
"items": store.list_records_page(limit=limit, offset=offset),
"limit": limit,
"offset": offset,
"total": store.count_records(),
}
@router.get(events_path, include_in_schema=False)
def events(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict[str, object]:
return {
"items": store.list_update_events_page(limit=limit, offset=offset),
"limit": limit,
"offset": offset,
"total": store.count_update_events(),
}
if admin_path == "/admin":
router.add_api_route("/records", records, methods=["GET"], include_in_schema=False)
router.add_api_route("/events", events, methods=["GET"], include_in_schema=False)
return router
router = make_router()
+179
View File
@@ -0,0 +1,179 @@
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 ..dependencies import ServiceDep, StoreDep
from ..schemas import (
CustomHostnameRequest,
DomainChallengeRequest,
DomainChallengeResponse,
ErrorResponse,
HostnameCredentials,
MagicHostnameRequest,
ManagedRecord,
RouterUpdateResponse,
VerifyDomainRequest,
VerifyDomainResponse,
_credentials_response,
_managed_record,
)
router = APIRouter(prefix="/api/v1")
@router.post(
"/hostnames/magic",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate a provider-owned DynDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_magic_hostname(payload: MagicHostnameRequest, service: ServiceDep) -> HostnameCredentials:
account = await run_in_threadpool(service.create_managed_account, payload.username)
return _credentials_response(service, account)
@router.get(
"/management/{management_slug}",
response_model=ManagedRecord,
summary="Read a hostname by magic management link",
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
tags=["management"],
responses={404: {"model": ErrorResponse}},
)
async def read_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> ManagedRecord:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return _managed_record(service, account)
@router.delete(
"/management/{management_slug}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by magic management link",
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
tags=["management"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "api")
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS delete failed; hostname was not deleted") from exc
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="Generate credentials for a verified custom hostname",
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,
summary="Router-compatible update endpoint",
tags=["updates"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def update_hostname(
update_slug: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RouterUpdateResponse:
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
outcome = await run_in_threadpool(
service.apply_update,
request,
hostname,
myip,
ipaddr,
myipv6,
ip6addr,
)
except BadIpError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
result = outcome.result
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse
from ..web.styles import APP_CSS
ASSET_DIR = Path(__file__).parents[1] / "assets"
router = APIRouter(include_in_schema=False)
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"}
APP_JS = """
(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const storageKey = "karldns-theme";
const systemTheme = () => (media.matches ? "dark" : "light");
const storedTheme = () => {
try {
return window.localStorage.getItem(storageKey);
} catch (_) {
return null;
}
};
const setStoredTheme = (theme) => {
try {
window.localStorage.setItem(storageKey, theme);
} catch (_) {}
};
const syncToggle = (theme) => {
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.textContent = theme === "dark" ? "Dark" : "Light";
button.setAttribute("aria-label", `Switch to ${theme === "dark" ? "light" : "dark"} theme`);
});
};
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
const theme = requestedTheme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
syncToggle(theme);
return theme;
};
applyTheme();
media.addEventListener("change", () => {
if (!storedTheme()) applyTheme();
});
document.addEventListener("DOMContentLoaded", () => syncToggle(document.documentElement.getAttribute("data-bs-theme") || "light"));
document.addEventListener("click", (event) => {
const toggle = event.target.closest("[data-theme-toggle]");
if (!toggle) return;
const currentTheme = document.documentElement.getAttribute("data-bs-theme") || systemTheme();
const nextTheme = currentTheme === "dark" ? "light" : "dark";
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
})();
document.addEventListener("click", async (event) => {
const toggle = event.target.closest("[data-secret-toggle]");
if (toggle) {
const group = toggle.closest(".copy-field");
const field = group ? group.querySelector("[data-secret-input], [data-secret-value]") : null;
if (!field) return;
if (field.matches("[data-secret-input]")) {
const masked = field.type === "password";
field.type = masked ? "text" : "password";
toggle.textContent = masked ? "Hide" : "View";
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
} else {
const masked = field.dataset.secretMasked !== "false";
field.dataset.secretMasked = masked ? "false" : "true";
field.textContent = masked ? (field.dataset.secretValue || "") : "********";
toggle.textContent = masked ? "Hide" : "View";
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
}
return;
}
const button = event.target.closest("[data-copy]");
if (!button) return;
try {
await navigator.clipboard.writeText(button.dataset.copy || "");
const old = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = old;
}, 1200);
} catch (_) {}
});
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
const table = tableShell.querySelector("table");
const tbody = tableShell.querySelector("tbody");
const search = tableShell.querySelector("[data-table-search]");
const pageSize = tableShell.querySelector("[data-table-page-size]");
const count = tableShell.querySelector("[data-table-count]");
const pagination = tableShell.querySelector("[data-table-pagination]");
if (!table || !tbody || !search || !pageSize || !count || !pagination) return;
const headerCount = table.querySelectorAll("thead th").length || 1;
const originalRows = Array.from(tbody.querySelectorAll("tr"));
const dataRows = originalRows.filter((row) => !row.classList.contains("empty-row"));
let currentPage = 1;
const emptyRow = (message) => {
const row = document.createElement("tr");
row.className = "empty-row";
const cell = document.createElement("td");
cell.className = "empty";
cell.colSpan = headerCount;
cell.textContent = message;
row.append(cell);
return row;
};
const visibleRows = () => {
const query = search.value.trim().toLowerCase();
if (!query) return dataRows;
return dataRows.filter((row) => row.textContent.toLowerCase().includes(query));
};
const renderButton = (label, page, disabled = false, active = false) => {
const item = document.createElement("li");
item.className = `page-item${disabled ? " disabled" : ""}${active ? " active" : ""}`;
const button = document.createElement("button");
button.type = "button";
button.className = "page-link";
button.textContent = label;
button.disabled = disabled;
button.addEventListener("click", () => {
currentPage = page;
render();
});
item.append(button);
return item;
};
const renderPagination = (totalPages) => {
pagination.replaceChildren();
if (totalPages <= 1) return;
pagination.append(renderButton("Previous", Math.max(1, currentPage - 1), currentPage === 1));
for (let page = 1; page <= totalPages; page += 1) {
if (totalPages > 7 && page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) {
if (page === 2 || page === totalPages - 1) pagination.append(renderButton("...", currentPage, true));
continue;
}
pagination.append(renderButton(String(page), page, false, page === currentPage));
}
pagination.append(renderButton("Next", Math.min(totalPages, currentPage + 1), currentPage === totalPages));
};
const render = () => {
const rows = visibleRows();
const size = Number.parseInt(pageSize.value, 10) || 25;
const totalPages = Math.max(1, Math.ceil(rows.length / size));
currentPage = Math.min(currentPage, totalPages);
const start = (currentPage - 1) * size;
const pageRows = rows.slice(start, start + size);
tbody.replaceChildren(...(pageRows.length ? pageRows : [emptyRow(dataRows.length ? "No matching rows." : "No rows yet.")]));
const shownStart = rows.length ? start + 1 : 0;
const shownEnd = rows.length ? Math.min(start + size, rows.length) : 0;
count.textContent = `${shownStart}-${shownEnd} of ${rows.length}`;
renderPagination(totalPages);
};
search.addEventListener("input", () => {
currentPage = 1;
render();
});
pageSize.addEventListener("change", () => {
currentPage = 1;
render();
});
render();
});
""".strip()
@router.get("/favicon.ico")
def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-16x16.png")
def favicon_16_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-32x32.png")
def favicon_32_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/apple-touch-icon.png")
def apple_touch_icon_png() -> FileResponse:
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.png")
def logo_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-192.png")
def logo_192_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-512.png")
def logo_512_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/site.webmanifest")
def site_webmanifest() -> Response:
return Response(
"""
{
"name": "KarlDNS",
"short_name": "KarlDNS",
"description": "Accountless Dynamic DNS for routers and home labs.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"}
]
}
""".strip(),
media_type="application/manifest+json",
headers=ASSET_CACHE_HEADERS,
)
@router.get("/app.js")
def app_js() -> Response:
return Response(APP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/app.css")
def app_css() -> Response:
return Response(APP_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/install.sh")
def install_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
status_code=302,
)
@router.get("/uninstall.sh")
def uninstall_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
status_code=302,
)
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from typing import NoReturn
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import (
DASHBOARD_HOSTNAME_DELETED,
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
DASHBOARD_UPDATE_URL_ROTATED,
)
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 .management_shared import (
get_active_management_account,
get_active_management_account_async,
has_management_access,
locked_management_response,
render_management_page_async,
)
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)
def manage_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account)
if not has_management_access(request, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token)
return _render_management_page(service, settings, management_slug, account, csrf_token)
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
async def rotate_magic_credentials(
management_slug: str,
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, 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"))
hostname = str(account["hostname"])
action = first_form_value(form, "action")
if action == "update":
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
if not rotated:
return _not_found()
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_UPDATE_URL_ROTATED,
"router update URL rotated",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Update-URL rotated.",
)
if action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
if not rotated:
return _not_found()
rotated["management_slug"] = management_slug
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
"router credentials rotated",
client_ip(request, settings),
)
return await render_management_page_async(
service,
settings,
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Router credentials rotated. Copy the new password now.",
created_account=rotated,
)
raise _bad_rotation_action()
@router.post("/m/{management_slug}/delete", include_in_schema=False)
async def delete_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "management-link")
except DnsProviderError:
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_HOSTNAME_DELETED,
"hostname deleted from dashboard",
client_ip(request, settings),
)
return HTMLResponse(
_page(
"DynDNS deleted",
trusted(
"""
<main>
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Deleted</p>
<h1>Hostname deleted.</h1>
<p class="lead">The update URL no longer works.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
</section>
</main>
"""
),
)
)
def _not_found() -> NoReturn:
raise HTTPException(status_code=404, detail="not found")
def _bad_rotation_action() -> NoReturn:
raise HTTPException(status_code=400, detail="invalid rotation action")
+153
View File
@@ -0,0 +1,153 @@
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,
)
router = APIRouter()
@router.get("/d/{claim_secret}", response_class=HTMLResponse, include_in_schema=False)
async def manage_domain_claim(claim_secret: str, 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: str,
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, 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="Domain 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: str,
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, 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 now create a custom hostname." if found else "TXT record not found yet. DNS propagation can take 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: str,
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, 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="Custom hostname created. Copy these router settings now.",
created_account=created,
)
@@ -0,0 +1,146 @@
from __future__ import annotations
from fastapi import APIRouter, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..dependencies import ServiceDep, SettingsDep, StoreDep
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
from ..web.forms import first_form_value, request_form
from ..web.templates_management import _render_management_unlock_page
from .management_shared import (
delete_unlock_cookie,
get_active_management_account_async,
has_management_access,
management_error_response_async,
render_management_page_async,
set_unlock_cookie,
unlock_cookie_seconds,
)
router = APIRouter()
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not account.get("dashboard_password_hash"):
return RedirectResponse(f"/m/{management_slug}", status_code=303)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
password = first_form_value(form, "dashboard_password")
hostname = str(account["hostname"])
is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password)
if not is_valid:
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect dashboard password."),
status_code=401,
)
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
set_unlock_cookie(
response,
settings,
management_slug,
account,
unlock_cookie_seconds(settings, first_form_value(form, "stay_unlocked")),
)
return response
@router.post("/m/{management_slug}/password", include_in_schema=False)
async def set_dashboard_password(
management_slug: str,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if account.get("dashboard_password_hash"):
is_current = await run_in_threadpool(
store.verify_dashboard_password,
hostname,
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
password = first_form_value(form, "dashboard_password")
confirm = first_form_value(form, "dashboard_password_confirm")
if len(password) < 8:
page = await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Dashboard password must be at least 8 characters.",
)
return HTMLResponse(page, status_code=400)
if password != confirm:
page = await render_management_page_async(
service,
settings,
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Dashboard password confirmation did not match.",
)
return HTMLResponse(page, status_code=400)
had_password = bool(account.get("dashboard_password_hash"))
await run_in_threadpool(store.set_dashboard_password, hostname, password)
account = await get_active_management_account_async(store, management_slug)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_PASSWORD_CHANGED if had_password else DASHBOARD_PASSWORD_ENABLED,
"dashboard password changed" if had_password else "dashboard password enabled",
client_ip(request, settings),
)
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
set_unlock_cookie(response, settings, management_slug, account, settings.dashboard_unlock_seconds)
return response
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
async def remove_dashboard_password(
management_slug: str,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if account.get("dashboard_password_hash"):
is_current = await run_in_threadpool(
store.verify_dashboard_password,
hostname,
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
await run_in_threadpool(store.clear_dashboard_password, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_PASSWORD_REMOVED,
"dashboard password removed",
client_ip(request, settings),
)
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
delete_unlock_cookie(response, management_slug)
return response
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from fastapi import HTTPException, Request, Response
from fastapi.responses import HTMLResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_models import DdnsSettings
from ..ddns_security import (
DASHBOARD_UNLOCK_COOKIE,
dashboard_csrf_token,
sign_dashboard_unlock_cookie,
verify_dashboard_unlock_cookie,
)
from ..ddns_service import DdnsService
from ..ddns_store import DdnsStore
from ..store.types import AccountRow
from ..web.templates_management import _render_management_page, _render_management_unlock_page
ManagementAccount = AccountRow
def get_active_management_account(store: DdnsStore, management_slug: str) -> ManagementAccount:
account = store.get_account_by_management_slug(management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return account
async def get_active_management_account_async(store: DdnsStore, management_slug: str) -> ManagementAccount:
return await run_in_threadpool(get_active_management_account, store, management_slug)
def has_management_access(request: Request, management_slug: str, account: ManagementAccount) -> bool:
password_hash = account.get("dashboard_password_hash")
if not password_hash:
return True
return verify_dashboard_unlock_cookie(
str(account["hostname"]),
management_slug,
str(password_hash),
request.cookies.get(DASHBOARD_UNLOCK_COOKIE),
)
def locked_management_response(management_slug: str, account: ManagementAccount) -> HTMLResponse:
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
def management_error_response(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
) -> HTMLResponse:
return HTMLResponse(
_render_management_page(service, settings, management_slug, account, dashboard_csrf_token(management_slug, account), message=message),
status_code=400,
)
async def management_error_response_async(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
message: str,
) -> HTMLResponse:
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message)
async def render_management_page_async(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
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(
_render_management_page,
service,
settings,
management_slug,
account,
csrf_token,
message,
challenge,
created_account,
)
def set_unlock_cookie(
response: Response,
settings: DdnsSettings,
management_slug: str,
account: ManagementAccount,
max_age_seconds: int,
) -> None:
password_hash = account.get("dashboard_password_hash")
if not password_hash:
return
response.set_cookie(
DASHBOARD_UNLOCK_COOKIE,
sign_dashboard_unlock_cookie(str(account["hostname"]), management_slug, str(password_hash), max_age_seconds),
max_age=max_age_seconds,
path=f"/m/{management_slug}",
httponly=True,
samesite="lax",
secure=settings.public_base_url.startswith("https://"),
)
def delete_unlock_cookie(response: Response, management_slug: str) -> None:
response.delete_cookie(DASHBOARD_UNLOCK_COOKIE, path=f"/m/{management_slug}", httponly=True, samesite="lax")
def unlock_cookie_seconds(settings: DdnsSettings, stay_unlocked: str) -> int:
if stay_unlocked:
return settings.dashboard_stay_unlocked_seconds
return settings.dashboard_unlock_seconds
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
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
router = APIRouter()
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
def index(settings: SettingsDep, service: ServiceDep) -> str:
return _render_public_home(service, settings, None, None)
@router.get("/magic", include_in_schema=False)
def magic_form() -> RedirectResponse:
return RedirectResponse("/#create", status_code=303)
@router.post("/magic", response_class=HTMLResponse, include_in_schema=False)
async def magic_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
await request_form(request, settings)
account = await run_in_threadpool(service.create_managed_account, None)
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. DNS propagation can take a few minutes."
return _render_public_home(service, settings, None, message, challenge)
return _render_public_home(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge)
@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,
)
return _render_public_home(service, settings, account, None)
@router.get("/healthz", response_class=PlainTextResponse)
def healthz() -> str:
return "ok"
@router.get("/ip", response_class=PlainTextResponse)
def visible_ip(request: Request, settings: SettingsDep) -> str:
return client_ip(request, settings)
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
def privacy() -> str:
return _render_privacy_page()
+60
View File
@@ -0,0 +1,60 @@
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
router = APIRouter()
@router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
def slug_update(
update_slug: str,
request: Request,
store: StoreDep,
service: ServiceDep,
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
account = store.get_account_by_slug(update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return apply_update_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),
hostname: str | None = Query(default=None),
domain: str | None = Query(default=None),
myip: str | None = Query(default=None),
ipaddr: str | None = Query(default=None),
myipv6: str | None = Query(default=None),
ip6addr: str | None = Query(default=None),
) -> str | PlainTextResponse:
host = (hostname or domain or "").strip().lower().rstrip(".")
if not host:
return PlainTextResponse("nohost", status_code=400)
if settings.allowed_hosts and host not in settings.allowed_hosts:
return PlainTextResponse("nohost", status_code=403)
supplied_user, supplied_token = credentials
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:
return PlainTextResponse("badauth", status_code=401)
return apply_update_response(service, request, host, myip, ipaddr, myipv6, ip6addr)
+97
View File
@@ -0,0 +1,97 @@
from pydantic import BaseModel, ConfigDict, Field
from .ddns_service import DdnsService
class ErrorResponse(BaseModel):
detail: str = Field(examples=["not found"])
class MagicHostnameRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class HostnameCredentials(BaseModel):
hostname: str
username: str
password: str = Field(description="Secret router password. Store it now; it is not recoverable later.")
update_url: str = Field(description="Secret bearer update URL for router DynDNS updates.")
credentialed_update_url: str = Field(
description="Secret update URL with router credentials embedded in the query string."
)
management_url: str = Field(description="Secret bearer management URL for reading or deleting this hostname.")
class ManagedRecord(BaseModel):
hostname: str
username: str
update_url: str = Field(description="Secret bearer update URL for router DynDNS updates.")
ipv4: str | None = None
ipv6: str | None = None
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 bearer token for returning to and verifying 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 bearer token for continuing 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
ip: str
def _credentials_response(service: DdnsService, account: dict[str, str]) -> HostnameCredentials:
return HostnameCredentials(
hostname=account["hostname"],
username=account["username"],
password=account["password"],
update_url=service.fritz_update_url(account),
credentialed_update_url=service.credentialed_update_url(account),
management_url=service.magic_management_url(account),
)
def _managed_record(service: DdnsService, account: dict[str, str | int | None]) -> ManagedRecord:
account_for_url = {"update_slug": str(account["update_slug"])}
return ManagedRecord(
hostname=str(account["hostname"]),
username=str(account["username"]),
update_url=service.fritz_update_url(account_for_url),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import threading
from contextlib import contextmanager
from pathlib import Path
from sqlite3 import Connection
from time import monotonic
from .accounts import AccountsMixin
from .domains import DomainsMixin
from .rate_limit import RateLimitCleanupMixin
from .records import RecordsEventsMixin
from .scheduler import SchedulerMixin
from .schema import connect_database, initialize_schema
class DdnsStore(AccountsMixin, DomainsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self._connections = threading.local()
self._connection_lock = threading.Lock()
self._tracked_connections: list[Connection] = []
self._closed_connection_ids: set[int] = set()
self._rate_limit_prune_lock = threading.Lock()
self._rate_limit_lock = threading.Lock()
self._rate_limit_buckets: dict[str, dict[str, float | int]] = {}
self._rate_limit_dirty_counts: dict[tuple[str, int], int] = {}
self._last_rate_limit_flush = monotonic()
self._last_rate_limit_prune_window = -1
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
@contextmanager
def connect(self):
conn = self._thread_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def _init_db(self) -> None:
with self.connect() as conn:
initialize_schema(conn)
def optimize(self) -> None:
with self.connect() as conn:
conn.execute("PRAGMA optimize")
def checkpoint_wal(self) -> None:
if str(self.database_path) == ":memory:":
return
with self.connect() as conn:
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
def _thread_connection(self) -> Connection:
conn = getattr(self._connections, "conn", None)
if conn is None or id(conn) in self._closed_connection_ids:
conn = connect_database(self.database_path)
self._connections.conn = conn
with self._connection_lock:
self._tracked_connections.append(conn)
self._closed_connection_ids.discard(id(conn))
return conn
def close_connections(self) -> None:
with self._connection_lock:
connections = list(self._tracked_connections)
self._tracked_connections.clear()
self._closed_connection_ids.update(id(conn) for conn in connections)
for conn in connections:
conn.close()
if getattr(self._connections, "conn", None) in connections:
self._connections.conn = None
close = close_connections
__all__ = ["DdnsStore"]
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import re
import secrets
import sqlite3
from datetime import UTC, datetime
from ..ddns_security import hash_lookup_token, hash_secret, verify_secret
from .types import AccountRow
class AccountsMixin:
def create_account(self, hostname: str, username: str | None = None) -> AccountRow:
now = datetime.now(UTC).isoformat()
token = secrets.token_urlsafe(24)
update_slug = secrets.token_urlsafe(32)
management_slug = secrets.token_urlsafe(32)
username = username or _default_username(hostname)
token_hash = hash_secret(token)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute(
"""
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
VALUES (?, ?, ?, ?, ?, ?, 0)
""",
(hostname, username, token_hash, update_slug, management_hash, now),
)
return {
"hostname": hostname,
"username": username,
"password": token,
"update_slug": update_slug,
"management_slug": management_slug,
}
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_accounts(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()
return int(row[0])
def get_account_by_hostname(self, hostname: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
""",
(hostname,),
).fetchone()
return dict(account) if account else None
def get_account_by_slug(self, update_slug: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
WHERE update_slug = ?
""",
(update_slug,),
).fetchone()
return dict(account) if account else None
def get_account_by_management_slug(self, management_slug: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ?
""",
(hash_lookup_token(management_slug),),
).fetchone()
return dict(account) if account else None
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool:
if not username or not token:
return False
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT token_hash, disabled FROM accounts
WHERE hostname = ? AND username = ?
""",
(hostname, username),
).fetchone()
if not account or account["disabled"]:
return False
return verify_secret(token, account["token_hash"])
def delete_account(self, hostname: str) -> None:
with self.connect() as conn:
conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,))
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
def rotate_update_slug(self, hostname: str) -> AccountRow | None:
update_slug = secrets.token_urlsafe(32)
with self.connect() as conn:
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
return self.get_account_by_hostname(hostname)
def rotate_management_slug(self, hostname: str) -> AccountRow | None:
management_slug = secrets.token_urlsafe(32)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["management_slug"] = management_slug
return account
def rotate_password(self, hostname: str) -> AccountRow | None:
token = secrets.token_urlsafe(24)
token_hash = hash_secret(token)
with self.connect() as conn:
conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["password"] = token
return account
def set_dashboard_password(self, hostname: str, password: str) -> AccountRow | None:
password_hash = hash_secret(password)
with self.connect() as conn:
conn.execute(
"UPDATE accounts SET dashboard_password_hash = ? WHERE hostname = ?",
(password_hash, hostname),
)
return self.get_account_by_hostname(hostname)
def clear_dashboard_password(self, hostname: str) -> AccountRow | None:
with self.connect() as conn:
conn.execute("UPDATE accounts SET dashboard_password_hash = NULL WHERE hostname = ?", (hostname,))
return self.get_account_by_hostname(hostname)
def verify_dashboard_password(self, hostname: str, password: str | None) -> bool:
if not password:
return False
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT dashboard_password_hash, disabled FROM accounts
WHERE hostname = ?
""",
(hostname,),
).fetchone()
if not account or account["disabled"] or not account["dashboard_password_hash"]:
return False
return verify_secret(password, account["dashboard_password_hash"])
def _default_username(hostname: str) -> str:
label = hostname.split(".", 1)[0]
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
+139
View File
@@ -0,0 +1,139 @@
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:
conn.row_factory = sqlite3.Row
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:
conn.row_factory = sqlite3.Row
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:
conn.row_factory = sqlite3.Row
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:
conn.row_factory = sqlite3.Row
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])
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import sqlite3
import time
from datetime import UTC, datetime, timedelta
from .types import CleanupRunRow
RATE_LIMIT_FLUSH_SECONDS = 10.0
class RateLimitCleanupMixin:
def list_cleanup_runs(self, limit: int = 20, offset: int = 0) -> list[CleanupRunRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
FROM cleanup_runs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [CleanupRunRow(**dict(row)) for row in rows]
def count_cleanup_runs(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM cleanup_runs").fetchone()
return int(row[0])
def allow_rate_limit(self, key: str, limit: int) -> bool:
if limit <= 0:
return False
now = time.time()
window = int(now) // 60
refill_rate = limit / 60.0
with self._rate_limit_lock:
bucket = self._rate_limit_buckets.get(key)
if bucket is None:
bucket = {"tokens": float(limit), "updated_at": now, "window": window}
else:
elapsed_seconds = max(0.0, now - float(bucket["updated_at"]))
bucket["tokens"] = min(float(limit), float(bucket["tokens"]) + elapsed_seconds * refill_rate)
bucket["updated_at"] = now
bucket["window"] = window
if float(bucket["tokens"]) < 1.0:
self._rate_limit_buckets[key] = bucket
return False
bucket["tokens"] = float(bucket["tokens"]) - 1.0
self._rate_limit_buckets[key] = bucket
dirty_key = (key, window)
self._rate_limit_dirty_counts[dirty_key] = self._rate_limit_dirty_counts.get(dirty_key, 0) + 1
self._prune_memory_rate_limits(now)
self._flush_rate_limits_if_due()
return True
def flush_rate_limits(self) -> None:
with self._rate_limit_lock:
dirty_counts = self._rate_limit_dirty_counts
self._rate_limit_dirty_counts = {}
self._last_rate_limit_flush = time.monotonic()
if not dirty_counts:
return
try:
with self.connect() as conn:
conn.executemany(
"""
INSERT INTO rate_limits(key, window, count)
VALUES (?, ?, ?)
ON CONFLICT(key, window) DO UPDATE SET count=count + excluded.count
""",
[(key, window, count) for (key, window), count in dirty_counts.items()],
)
except Exception:
with self._rate_limit_lock:
for dirty_key, count in dirty_counts.items():
self._rate_limit_dirty_counts[dirty_key] = self._rate_limit_dirty_counts.get(dirty_key, 0) + count
raise
def _flush_rate_limits_if_due(self) -> None:
if time.monotonic() - self._last_rate_limit_flush < RATE_LIMIT_FLUSH_SECONDS:
return
self.flush_rate_limits()
def _prune_memory_rate_limits(self, now: float) -> None:
stale_before = now - 120
stale_keys = [
key
for key, bucket in self._rate_limit_buckets.items()
if float(bucket["updated_at"]) < stale_before and float(bucket["tokens"]) >= 1.0
]
for key in stale_keys:
del self._rate_limit_buckets[key]
def prune_rate_limit_storage(self) -> None:
current_window = int(time.time()) // 60
should_prune = self._should_prune_rate_limits(current_window)
if not should_prune:
return
with self.connect() as conn:
conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 2,))
def _should_prune_rate_limits(self, window: int) -> bool:
if self._last_rate_limit_prune_window == window:
return False
with self._rate_limit_prune_lock:
if self._last_rate_limit_prune_window == window:
return False
self._last_rate_limit_prune_window = window
return True
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
WHERE created_at < ?
AND NOT EXISTS (
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
)
""",
(unused_account_cutoff,),
)
rollup_cursor = conn.execute(
"DELETE FROM update_event_rollups WHERE last_seen_at < ?",
(unused_account_cutoff,),
)
event_cursor = conn.execute(
"DELETE FROM update_events WHERE created_at < ?",
(event_history_cutoff,),
)
current_window = int(time.time()) // 60
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 (?, ?, ?)
""",
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
)
return {
"domain_challenges": challenge_cursor.rowcount,
"unused_accounts": account_cursor.rowcount,
"event_rollups": rollup_cursor.rowcount,
"update_events": event_cursor.rowcount,
}
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from ..ddns_models import UpdateResult
from .types import UpdateEventRow
class RecordsEventsMixin:
def upsert(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(hostname, effective_ipv4, effective_ipv6, now),
)
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def commit_update_result(self, result: UpdateResult) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(result.hostname, result.ipv4, result.ipv6, now),
)
return result
def preview_update(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> UpdateResult:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def list_records(self) -> list[dict[str, str | None]]:
return self.list_records_page(limit=10_000, offset=0)
def list_records_page(self, limit: int = 100, offset: int = 0) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, updated_at
FROM records
ORDER BY hostname
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_records(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
return int(row[0])
def log_update_event(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()),
)
def log_update_rollup(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_event_rollups(
hostname, ipv4, ipv6, status, detail, source_ip, first_seen_at, last_seen_at, count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(hostname, ipv4, ipv6, status, detail, source_ip) DO UPDATE SET
last_seen_at=excluded.last_seen_at,
count=count + 1
""",
(hostname, ipv4 or "", ipv6 or "", status, detail[:500], source_ip, now, now),
)
def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
return self.list_update_events_page(limit=limit, offset=offset)
def list_update_events_page(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
source_limit = limit + offset
with self.connect() as conn:
conn.row_factory = sqlite3.Row
event_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(source_limit, 0),
).fetchall()
rollup_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
ORDER BY last_seen_at DESC
LIMIT ? OFFSET ?
""",
(source_limit, 0),
).fetchall()
return _merge_event_rows(event_rows, rollup_rows, limit, offset)
def count_update_events(self) -> int:
with self.connect() as conn:
event_count = conn.execute("SELECT COUNT(*) FROM update_events").fetchone()[0]
rollup_count = conn.execute("SELECT COUNT(*) FROM update_event_rollups").fetchone()[0]
return int(event_count) + int(rollup_count)
def list_update_events_for_hostname(self, hostname: str, limit: int = 25) -> list[UpdateEventRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
event_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
WHERE hostname = ?
ORDER BY created_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
rollup_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
WHERE hostname = ?
ORDER BY last_seen_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
return _merge_event_rows(event_rows, rollup_rows, limit, 0)
def _merge_event_rows(
event_rows: list[sqlite3.Row],
rollup_rows: list[sqlite3.Row],
limit: int,
offset: int,
) -> list[UpdateEventRow]:
rows = [dict(row) for row in event_rows]
rows.extend(dict(row) for row in rollup_rows)
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
return rows[offset : offset + limit]
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime, timedelta
from .types import ScheduledTaskRow
def _now() -> datetime:
return datetime.now(UTC)
def _iso(value: datetime) -> str:
return value.isoformat()
class SchedulerMixin:
def ensure_scheduled_task(self, name: str, interval_seconds: int) -> None:
now = _now()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO scheduled_tasks(name, interval_seconds, next_run_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
interval_seconds=excluded.interval_seconds,
updated_at=excluded.updated_at
""",
(name, interval_seconds, _iso(now), _iso(now)),
)
def acquire_due_task(self, name: str, lease_owner: str, lease_seconds: int) -> bool:
now = _now()
lease_expires_at = now + timedelta(seconds=lease_seconds)
with self.connect() as conn:
cursor = conn.execute(
"""
UPDATE scheduled_tasks
SET lease_owner = ?,
lease_expires_at = ?,
last_started_at = ?,
last_status = 'running',
last_error = '',
updated_at = ?
WHERE name = ?
AND next_run_at <= ?
AND (lease_expires_at IS NULL OR lease_expires_at <= ?)
""",
(
lease_owner,
_iso(lease_expires_at),
_iso(now),
_iso(now),
name,
_iso(now),
_iso(now),
),
)
return cursor.rowcount == 1
def complete_scheduled_task(
self,
name: str,
interval_seconds: int,
success: bool,
error: str = "",
) -> None:
now = _now()
next_run_at = now + timedelta(seconds=interval_seconds)
status = "ok" if success else "error"
with self.connect() as conn:
conn.execute(
"""
UPDATE scheduled_tasks
SET next_run_at = ?,
lease_owner = NULL,
lease_expires_at = NULL,
last_finished_at = ?,
last_status = ?,
last_error = ?,
interval_seconds = ?,
updated_at = ?
WHERE name = ?
""",
(_iso(next_run_at), _iso(now), status, error[:500], interval_seconds, _iso(now), name),
)
def list_scheduled_tasks(self) -> list[ScheduledTaskRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at,
last_started_at, last_finished_at, last_status, last_error, updated_at
FROM scheduled_tasks
ORDER BY name
"""
).fetchall()
return [ScheduledTaskRow(**dict(row)) for row in rows]
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
import secrets
import sqlite3
from pathlib import Path
from ..ddns_security import hash_lookup_token
BUSY_TIMEOUT_MS = 10_000
CACHE_SIZE_KIB = 20_000
MMAP_SIZE_BYTES = 256 * 1024 * 1024
WAL_AUTOCHECKPOINT_PAGES = 1_000
SCHEMA_VERSION = 1
def connect_database(database_path: Path) -> sqlite3.Connection:
if str(database_path) != ":memory:":
database_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(database_path, check_same_thread=False)
configure_connection(conn)
return conn
def configure_connection(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA temp_store = MEMORY")
conn.execute(f"PRAGMA cache_size = -{CACHE_SIZE_KIB}")
conn.execute(f"PRAGMA mmap_size = {MMAP_SIZE_BYTES}")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute(f"PRAGMA wal_autocheckpoint = {WAL_AUTOCHECKPOINT_PAGES}")
def initialize_schema(conn: sqlite3.Connection) -> None:
configure_connection(conn)
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS records (
hostname TEXT PRIMARY KEY,
ipv4 TEXT,
ipv6 TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ipv4 TEXT,
ipv6 TEXT,
status TEXT NOT NULL,
detail TEXT NOT NULL,
source_ip TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_hostname_created_at ON update_events(hostname, created_at DESC)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_event_rollups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ipv4 TEXT,
ipv6 TEXT,
status TEXT NOT NULL,
detail TEXT NOT NULL,
source_ip TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
count INTEGER NOT NULL,
UNIQUE(hostname, ipv4, ipv6, status, detail, source_ip)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_event_rollups_last_seen_at ON update_event_rollups(last_seen_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_update_event_rollups_hostname_last_seen_at
ON update_event_rollups(hostname, last_seen_at DESC)
"""
)
conn.execute(
"""
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
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS scheduled_tasks (
name TEXT PRIMARY KEY,
interval_seconds INTEGER NOT NULL,
next_run_at TEXT NOT NULL,
lease_owner TEXT,
lease_expires_at TEXT,
last_started_at TEXT,
last_finished_at TEXT,
last_status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run_at ON scheduled_tasks(next_run_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
ON scheduled_tasks(next_run_at, lease_expires_at)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
window INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY(key, window)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE,
dashboard_password_hash TEXT,
created_at TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
_ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT")
_ensure_column(conn, "accounts", "dashboard_password_hash", "TEXT")
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
conn.execute(
"UPDATE accounts SET management_hash = ? WHERE hostname = ?",
(hash_lookup_token(secrets.token_urlsafe(32)), row[0]),
)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
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")
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
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}")
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import TypedDict
class AccountRow(TypedDict, total=False):
hostname: str
username: str
token_hash: str
update_slug: str
management_slug: str
dashboard_password_hash: str | None
created_at: str
disabled: int
ipv4: str | None
ipv6: str | None
updated_at: str | None
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
ipv6: str | None
status: str
detail: str
source_ip: str
created_at: str
count: int
class CleanupRunRow(TypedDict):
created_at: str
domain_challenges_deleted: int
unused_accounts_deleted: int
class ScheduledTaskRow(TypedDict, total=False):
name: str
interval_seconds: int
next_run_at: str
lease_owner: str | None
lease_expires_at: str | None
last_started_at: str | None
last_finished_at: str | None
last_status: str
last_error: str
updated_at: str
View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import urllib.parse
from fastapi import HTTPException, Request
from ..ddns_models import DdnsSettings
MAX_FORM_FIELDS = 100
def parse_form(body: bytes) -> dict[str, list[str]]:
try:
decoded = body.decode("utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="form body must be valid UTF-8") from exc
try:
return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields") from exc
async def request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]:
body = await request.body()
if len(body) > settings.max_request_body_bytes:
raise HTTPException(status_code=413, detail="request body too large")
return parse_form(body)
def first_form_value(form: dict[str, list[str]], key: str) -> str:
return form.get(key, [""])[0].strip()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import html
class SafeHtml(str):
"""HTML fragment that was built by trusted server-side template helpers."""
def escape(value: object) -> str:
return html.escape(str(value), quote=True)
def attr(value: object) -> str:
return escape(value)
def trusted(fragment: str) -> SafeHtml:
return SafeHtml(fragment)
def join_html(fragments: list[str]) -> SafeHtml:
return SafeHtml("".join(fragments))
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from starlette.concurrency import run_in_threadpool
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..ddns_models import DdnsSettings
from ..ddns_security import client_ip, is_rate_limited_path, rate_limit_bucket
from ..ddns_store import DdnsStore
class RequestBodyTooLargeError(Exception):
pass
class RequestBodyLimitMiddleware:
def __init__(self, app: ASGIApp, max_body_bytes: int) -> None:
self.app = app
self.max_body_bytes = max_body_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
received = 0
async def limited_receive() -> Message:
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > self.max_body_bytes:
raise RequestBodyTooLargeError
return message
try:
await self.app(scope, limited_receive, send)
except RequestBodyTooLargeError:
response = PlainTextResponse("request body too large", status_code=413)
await response(scope, receive, send)
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
try:
content_length = int(request.headers.get("content-length", "0") or "0")
except ValueError:
content_length = 0
if content_length > settings.max_request_body_bytes:
return PlainTextResponse("request body too large", status_code=413)
if is_rate_limited_path(request.url.path) or is_admin_path(request.url.path, settings):
limit = (
settings.admin_rate_limit_per_minute
if is_admin_path(request.url.path, settings)
else settings.rate_limit_per_minute
)
key = f"{client_ip(request, settings)}:{rate_limit_bucket(request.url.path)}"
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed:
return PlainTextResponse("rate limit exceeded", status_code=429)
response = await call_next(request)
if is_secret_response(request):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
response.headers["Content-Security-Policy"] = content_security_policy(request.url.path)
if settings.public_base_url.startswith("https://"):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
admin_path = settings.admin_path.rstrip("/")
admin_records = f"{admin_path}/records"
admin_events = f"{admin_path}/events"
legacy_admin_api = settings.admin_path == "/admin" and path in {"/records", "/events"}
return path == admin_path or path.startswith(f"{admin_path}/") or path in {admin_records, admin_events} or legacy_admin_api
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/")):
return True
if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
return request.method in {"POST", "DELETE"}
return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update"
def content_security_policy(path: str) -> str:
if path in {"/docs", "/redoc", "/openapi.json"}:
return (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https://fastapi.tiangolo.com; "
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
return (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' https://cdn.jsdelivr.net; "
"img-src 'self' data:; "
"font-src 'self' https://cdn.jsdelivr.net; "
"connect-src 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
+780
View File
@@ -0,0 +1,780 @@
from __future__ import annotations
APP_CSS = """
:root {
color-scheme: light;
--bs-primary: #000000;
--bs-primary-rgb: 0, 0, 0;
--bs-link-color: #000000;
--bs-link-hover-color: #3f3f46;
--bs-border-radius: 12px;
--bs-border-radius-sm: 10px;
--bs-border-radius-lg: 12px;
--rp-bg: #ffffff;
--rp-canvas: #ffffff;
--rp-surface: #ffffff;
--rp-surface-alt: #fafafa;
--rp-field: #fafafa;
--rp-ink: #000000;
--rp-muted: #737373;
--rp-charcoal: #52525b;
--rp-mute: #a1a1aa;
--rp-line: #e5e5e5;
--rp-soft-line: #eeeeee;
--rp-danger: #b42318;
--rp-focus-ring: rgba(0, 0, 0, 0.18);
--rp-terminal: #ffffff;
--rp-terminal-ink: #000000;
--rp-success: #1d6f42;
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-body-bg: #0f0f0f;
--bs-body-color: #f5f5f5;
--bs-secondary-color: #a1a1aa;
--bs-border-color: rgba(255, 255, 255, 0.16);
--bs-tertiary-bg: #171717;
--rp-bg: #0f0f0f;
--rp-canvas: #0f0f0f;
--rp-surface: #171717;
--rp-surface-alt: #1f1f1f;
--rp-field: #1f1f1f;
--rp-ink: #f5f5f5;
--rp-muted: #a3a3a3;
--rp-charcoal: #d4d4d4;
--rp-mute: #737373;
--rp-line: rgba(255, 255, 255, 0.16);
--rp-soft-line: rgba(255, 255, 255, 0.1);
--rp-danger: #ff8a80;
--rp-focus-ring: rgba(255, 255, 255, 0.24);
--rp-terminal: #171717;
--rp-terminal-ink: #f5f5f5;
--rp-success: #62c383;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
overflow-x: clip;
}
body {
min-height: 100vh;
overflow-x: clip;
background: var(--rp-bg);
color: var(--rp-ink);
font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.justify-items-center {
justify-items: center;
}
.container {
max-width: 1040px;
}
.container.narrow {
max-width: 760px;
}
.skip-link {
position: fixed;
left: 16px;
top: 10px;
z-index: 40;
transform: translateY(-140%);
padding: 8px 20px;
border-radius: 999px;
background: var(--rp-ink);
color: var(--rp-bg);
text-decoration: none;
font-size: 14px;
transition: transform 0.16s ease;
}
.skip-link:focus {
transform: translateY(0);
}
.site-nav {
min-height: 63px;
padding: 14px 0;
border-bottom: 0;
background: var(--rp-canvas);
}
.site-nav .container {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
color: var(--rp-ink);
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
white-space: nowrap;
}
.brand:hover {
color: var(--rp-ink);
}
.brand img {
width: 26px;
height: 26px;
display: block;
}
.nav-label {
max-width: min(38vw, 320px);
}
.site-nav-actions {
min-width: 0;
flex-wrap: wrap;
}
.navbar .nav-link,
.navbar-text {
color: var(--rp-muted);
font-size: 13px;
font-weight: 500;
}
.navbar .nav-link:hover {
color: var(--rp-ink);
}
.theme-toggle {
min-width: 58px;
}
.hero-band {
min-height: auto;
display: grid;
align-items: center;
border-bottom: 0;
background: var(--rp-canvas);
text-align: center;
padding: 96px 0 88px;
}
.hero-band .container {
max-width: 720px;
min-width: 0;
}
.hero-band .container > * {
max-width: 100%;
min-width: 0;
}
.hero-logo {
width: 80px;
height: 80px;
margin-inline: auto;
}
.hero-band.compact {
min-height: auto;
padding: 56px 0 48px;
}
.section {
padding: 88px 0;
border-bottom: 0;
background: var(--rp-canvas);
}
.section + .section {
border-top: 0;
}
.success-section {
background: var(--rp-surface-alt);
}
.danger-section {
background: var(--rp-surface);
border-top: 1px solid var(--rp-soft-line);
}
.page-heading,
.admin-heading .container {
max-width: 760px;
}
.admin-heading {
padding: 56px 0 48px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1,
h2 {
font-family: "SF Pro Rounded", ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
h1 {
max-width: 720px;
font-size: 36px;
line-height: 1.11;
font-weight: 500;
letter-spacing: 0;
overflow-wrap: anywhere;
}
h2 {
font-size: 28px;
line-height: 1.22;
font-weight: 500;
letter-spacing: 0;
}
h3 {
font-size: 19px;
line-height: 1.35;
font-weight: 500;
}
.eyebrow {
color: var(--rp-charcoal);
font-size: 13px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
.lead {
max-width: 660px;
color: var(--rp-muted);
font-size: 16px;
line-height: 1.5;
letter-spacing: 0;
}
.section-copy,
.intro {
margin-top: 12px;
max-width: 560px;
color: var(--rp-muted);
font-size: 15px;
line-height: 1.5;
}
.install-pill {
display: inline-flex;
align-items: center;
max-width: 100%;
min-width: 0;
min-height: 48px;
padding: 12px 20px;
border: 0;
border-radius: 9999px;
background: var(--rp-field);
color: var(--rp-ink);
}
.install-pill code {
min-width: 0;
overflow: hidden;
color: var(--rp-ink);
text-overflow: ellipsis;
white-space: nowrap;
font: 400 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.card {
background: var(--rp-surface);
border: 1px solid var(--rp-line);
border-radius: 16px;
box-shadow: none;
overflow: clip;
}
.card-body {
padding: 24px;
}
label {
display: grid;
gap: 8px;
color: var(--rp-charcoal);
font-size: 14px;
font-weight: 500;
}
.form-control,
input {
width: 100%;
min-height: 44px;
border: 1px solid var(--rp-line);
border-radius: 9999px;
padding: 10px 16px;
color: var(--bs-body-color);
font: inherit;
background: var(--rp-field);
outline: none;
}
.form-select {
min-height: 38px;
border-color: var(--rp-line);
border-radius: 9999px;
background-color: var(--rp-field);
color: var(--rp-ink);
}
.form-control:focus,
input:focus,
.form-select:focus {
border-color: var(--rp-ink);
box-shadow: 0 0 0 3px var(--rp-focus-ring);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 9999px;
font-size: 14px;
font-weight: 600;
line-height: 1;
transition: background-color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.btn:not(.btn-sm) {
min-height: 42px;
padding: 10px 22px;
}
.btn-sm {
min-width: 34px;
min-height: 34px;
padding-inline: 14px;
border-radius: 9999px;
}
.btn:active {
transform: scale(0.97);
}
.btn:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--rp-focus-ring);
outline-offset: 3px;
}
.btn-primary {
--bs-btn-color: #ffffff;
--bs-btn-bg: #000000;
--bs-btn-border-color: #000000;
--bs-btn-hover-color: #ffffff;
--bs-btn-hover-bg: #1c1c1c;
--bs-btn-hover-border-color: #1c1c1c;
--bs-btn-active-color: #ffffff;
--bs-btn-active-bg: #1c1c1c;
--bs-btn-active-border-color: #1c1c1c;
--bs-btn-disabled-bg: var(--rp-surface-alt);
--bs-btn-disabled-border-color: var(--rp-line);
--bs-btn-disabled-color: var(--rp-mute);
}
.btn-outline-primary {
--bs-btn-color: var(--rp-ink);
--bs-btn-border-color: var(--rp-line);
--bs-btn-hover-color: var(--rp-ink);
--bs-btn-hover-bg: var(--rp-surface-alt);
--bs-btn-hover-border-color: var(--rp-line);
--bs-btn-active-color: var(--rp-ink);
--bs-btn-active-bg: var(--rp-surface-alt);
--bs-btn-active-border-color: var(--rp-line);
}
.btn-outline-secondary,
.btn-light {
--bs-btn-bg: var(--rp-field);
--bs-btn-border-color: var(--rp-line);
--bs-btn-color: var(--rp-ink);
--bs-btn-hover-bg: var(--rp-surface-alt);
--bs-btn-hover-border-color: var(--rp-line);
--bs-btn-hover-color: var(--rp-ink);
}
.btn-outline-danger {
--bs-btn-color: var(--rp-danger);
--bs-btn-border-color: var(--rp-line);
--bs-btn-hover-color: #ffffff;
--bs-btn-hover-bg: var(--rp-danger);
--bs-btn-hover-border-color: var(--rp-danger);
}
.text-primary {
color: var(--rp-ink) !important;
}
.text-bg-primary {
color: #ffffff !important;
background-color: #000000 !important;
}
.text-bg-secondary,
.text-bg-warning {
color: var(--rp-charcoal) !important;
background-color: var(--rp-field) !important;
border: 1px solid var(--rp-line);
}
.text-bg-primary.rounded-circle {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
border-radius: 9999px !important;
}
.alert-info,
.alert-warning {
--bs-alert-color: var(--rp-charcoal);
--bs-alert-bg: var(--rp-surface-alt);
--bs-alert-border-color: var(--rp-line);
border-radius: 12px;
}
.copy-field {
min-width: 0;
max-width: 100%;
}
.copy-field .input-group-text {
min-width: 150px;
border-color: var(--rp-line);
color: var(--rp-muted);
font-size: 13px;
background: var(--rp-surface-alt);
}
.copy-label {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.copy-field .form-control {
min-width: 0;
border-color: var(--rp-line);
background: var(--rp-field);
}
.copy-field .copy-value {
min-width: 0;
overflow-wrap: anywhere;
}
.copy-field code.form-control {
width: 1%;
flex: 1 1 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.copy-field code {
min-height: 42px;
margin: 0;
color: var(--rp-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
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;
}
.terminal-card {
display: grid;
gap: 8px;
padding: 18px 20px;
border: 1px solid var(--rp-line);
border-radius: 12px;
color: var(--rp-terminal-ink);
background: var(--rp-terminal);
font: 400 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.terminal-card code {
display: block;
overflow-wrap: anywhere;
color: inherit;
font: inherit;
}
.admin-table-scroll {
max-height: 560px;
overflow: auto;
background: var(--rp-surface);
border-color: var(--rp-line) !important;
border-radius: 16px !important;
-webkit-overflow-scrolling: touch;
}
.admin-table-scroll thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--rp-surface-alt);
}
.table-page-size {
width: auto;
min-width: 76px;
}
.admin-table-shell .form-control {
max-width: 360px;
}
.status-table-shell {
max-width: 100%;
border: 1px solid var(--rp-line);
border-radius: 16px;
background: var(--rp-surface);
}
.pagination {
--bs-pagination-color: var(--rp-muted);
--bs-pagination-bg: var(--rp-surface);
--bs-pagination-border-color: var(--rp-line);
--bs-pagination-hover-color: var(--rp-ink);
--bs-pagination-hover-bg: var(--rp-field);
--bs-pagination-hover-border-color: var(--rp-line);
--bs-pagination-active-color: #ffffff;
--bs-pagination-active-bg: #000000;
--bs-pagination-active-border-color: #000000;
--bs-pagination-disabled-color: var(--rp-mute);
--bs-pagination-disabled-bg: var(--rp-surface);
--bs-pagination-disabled-border-color: var(--rp-line);
}
.site-footer {
padding: 32px 0;
background: var(--rp-canvas);
}
.site-footer .nav-link:hover {
color: var(--rp-ink) !important;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: var(--rp-ink);
}
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--rp-ink);
--bs-table-border-color: var(--rp-soft-line);
--bs-table-hover-bg: var(--rp-field);
margin-bottom: 0;
}
th,
td {
border-bottom: 1px solid var(--rp-soft-line);
padding: 14px 10px;
text-align: left;
white-space: nowrap;
}
td {
max-width: 38rem;
overflow: hidden;
text-overflow: ellipsis;
}
.status-table {
min-width: min(100%, 420px);
}
.status-table th,
.status-table td {
white-space: normal;
overflow-wrap: anywhere;
}
tr:last-child th,
tr:last-child td {
border-bottom: 0;
}
th {
color: var(--rp-muted);
font-size: 12px;
font-weight: 600;
background: var(--rp-surface-alt);
}
.empty {
color: var(--rp-muted);
text-align: center;
padding: 28px;
}
@media (max-width: 860px) {
.section,
.hero-band {
padding: 64px 0;
}
.hero-band {
min-height: auto;
}
h1 {
font-size: 32px;
}
}
@media (max-width: 560px) {
.container,
.container.narrow,
.hero-band .container {
width: 100%;
max-width: 100%;
}
.site-nav {
padding: 10px 0;
}
.site-nav .container {
align-items: flex-start;
}
.site-nav-actions {
max-width: calc(100% - 108px);
row-gap: 8px;
}
.brand {
font-size: 14px;
}
.brand img {
width: 24px;
height: 24px;
}
.navbar .nav-link,
.navbar-text {
font-size: 12px;
}
.nav-label {
max-width: 100%;
}
.hero-band .btn,
.card button,
.danger-section form,
.danger-section button {
width: 100%;
}
.install-pill {
width: 100%;
}
.hero-logo {
width: 68px;
height: 68px;
}
.copy-field {
display: grid;
grid-template-columns: 1fr;
}
.copy-field .input-group-text {
min-width: 0;
border-radius: 14px 14px 0 0;
white-space: normal;
}
.copy-field .copy-value,
.copy-field code {
width: 100%;
white-space: normal;
overflow-wrap: anywhere;
border-radius: 0;
}
.copy-field .btn {
width: 100%;
border-radius: 0;
}
.copy-field .btn:last-child {
border-radius: 0 0 14px 14px;
}
.admin-table-shell .form-control {
max-width: none;
}
.admin-table-shell [data-table-count] {
white-space: normal !important;
}
.pagination {
flex-wrap: wrap;
justify-content: flex-start !important;
}
th,
td {
padding: 12px 9px;
}
h1 {
font-size: 28px;
line-height: 1.15;
}
h2 {
font-size: 24px;
}
}
""".strip()
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from .html_utils import SafeHtml, attr, escape, join_html, trusted
def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtml:
account_html = ""
if label:
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">Docs</a><a class="nav-link px-0" href="/redoc">API</a>')
account_html += '<a class="btn btn-sm btn-primary rounded-pill" href="/#create">Generate</a>'
theme_toggle = '<button type="button" class="btn btn-sm btn-light theme-toggle" data-theme-toggle aria-label="Switch color theme">Light</button>'
return trusted(
f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand" href="/"><img src="/logo.png" alt="" width="28" height="28">KarlDNS</a>
<div class="navbar-nav site-nav-actions ms-auto flex-row align-items-center justify-content-end gap-2 gap-sm-3">
{links}
{account_html}
{theme_toggle}
</div>
</div>
</nav>
"""
)
def _copy_row(label: str, value: str, sensitivity: str | None = None) -> SafeHtml:
safe_label = escape(label)
safe_value = escape(value)
badge = ""
is_secret = bool(sensitivity and sensitivity.lower() == "secret")
if sensitivity:
safe_sensitivity = escape(sensitivity)
badge_class = "text-bg-warning" if is_secret else "text-bg-secondary"
badge = f'<span class="badge {badge_class} rounded-pill">{safe_sensitivity}</span>'
value_field = (
f'<input class="form-control copy-value text-truncate" type="password" value="{safe_value}" readonly data-secret-input>'
if is_secret
else f'<code class="form-control copy-value text-truncate">{safe_value}</code>'
)
reveal_button = (
f'<button type="button" class="btn btn-outline-secondary" data-secret-toggle aria-label="Show {safe_label}">View</button>'
if is_secret
else ""
)
return trusted(
f"""
<div class="input-group copy-field">
<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>
</div>
"""
)
def _copy_button(label: str, value: str) -> 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>')
def _manual_update_commands(update_url: str) -> str:
update_base = update_url.split("?", 1)[0]
return "\n".join(
[
f"curl -sS '{update_base}?myip=203.0.113.10'",
f"curl -sS '{update_base}?myipv6=2001:db8::10'",
f"curl -sS '{update_base}'",
]
)
def _manual_update_panel(commands: str) -> SafeHtml:
safe_commands = escape(commands)
return trusted(
f"""
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box replaces <code>&lt;ipaddr&gt;</code> and <code>&lt;ip6addr&gt;</code>, then sends a GET request to the Update-URL. Scripts and other routers can do the same with curl. If no IP parameter is sent, KarlDNS uses the request source IP.</p>
</div>
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
{_copy_button("Copy curl examples", commands)}
</div>
"""
)
def _fritzbox_fields(
update_url: str,
hostname: str,
username: str,
password: str,
management_url: str | None = None,
) -> str:
lines = [
f"Update-URL: {update_url}",
f"Domainnamen: {hostname}",
f"CNAME target: {hostname}",
f"Benutzername: {username}",
f"Kennwort: {password}",
]
if management_url:
lines.append(f"Private status page: {management_url}")
return "\n".join(lines)
def _secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Domainnamen and CNAME target can be shared in DNS.
<strong>Secret:</strong> Update-URL, one-box update URL, optional Kennwort, private dashboard URL, and domain claim links control the hostname.
Secrets are hidden by default on screen; Copy still copies the real value.
</div>
"""
)
def _one_time_secret_notice() -> SafeHtml:
return trusted(
"""
<div class="alert alert-warning my-3 py-2 small" role="alert">
Copy the router password and one-box update URL now. They are only shown when generated or rotated and are not recoverable from the dashboard later.
</div>
"""
)
def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
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>
</nav>
</div>
</footer>
"""
)
def _page(title: str, body: SafeHtml) -> str:
if not isinstance(body, SafeHtml):
raise TypeError("body must be a SafeHtml fragment built by a template helper")
return f"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<title>{escape(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<script src="/app.js" defer></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/app.css" rel="stylesheet">
</head>
<body>{body}{_site_footer()}</body>
</html>
"""
__all__ = [
"SafeHtml",
"_copy_button",
"_copy_row",
"_fritzbox_fields",
"_manual_update_commands",
"_manual_update_panel",
"_message_band",
"_one_time_secret_notice",
"_page",
"_secret_notice",
"_top_nav",
"join_html",
"trusted",
]
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
from urllib.parse import urlencode
from ..ddns_models import DdnsSettings
from ..ddns_security import admin_csrf_token
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import _page, _top_nav
from .templates_admin_tables import (
_account_row,
_admin_table_pagination,
_admin_table_toolbar,
_cleanup_row,
_domain_claim_row,
_event_row,
_scheduled_task_row,
)
def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
return trusted(f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Admin action</p>
<h2>{escape(title)}</h2>
<p class="section-copy">Copy this now. Secret values are only shown in this response.</p>
<div class="d-grid gap-2">
{"".join(rows)}
</div>
</div>
</section>
""")
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]],
table_counts: dict[str, int],
table_offsets: dict[str, int],
table_limit: int,
notice_html: SafeHtml | None,
) -> str:
csrf = attr(admin_csrf_token(settings))
admin_path = settings.admin_path
safe_admin_path = attr(admin_path)
safe_records_path = attr(f"{admin_path}/records")
safe_events_path = attr(f"{admin_path}/events")
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>
"""
cleanup_rows = "\n".join(_cleanup_row(run) for run in cleanup_runs) or """
<tr class="empty-row"><td colspan="3" class="empty">No cleanup runs yet.</td></tr>
"""
scheduled_task_rows = "\n".join(_scheduled_task_row(task) for task in scheduled_tasks) or """
<tr class="empty-row"><td colspan="6" class="empty">No scheduled tasks 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)))
cleanup_count = escape(table_counts.get("cleanup_runs", len(cleanup_runs)))
event_count = escape(table_counts.get("update_events", len(update_events)))
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),
)
cleanup_pager = _server_page_links(admin_path, "cleanup_runs_offset", table_offsets.get("cleanup_runs", 0), table_limit, table_counts.get("cleanup_runs", 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))
return _page(
"KarlDNS Admin",
trusted(f"""
<main id="main">
{_top_nav("Operator console", trusted(f'<a class="nav-link px-0" href="{safe_records_path}">Records</a><a class="nav-link px-0" href="{safe_events_path}">Events</a>'))}
<section class="section admin-heading">
<div class="container">
<p class="eyebrow">KarlDNS aktiv</p>
<h1>Operator console</h1>
<p class="lead">Inspect hostnames, verify domain claims, rotate links, run cleanup, and review router update events.</p>
</div>
</section>
{notice_html or ""}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<p class="eyebrow">Service health</p>
<h2>Scheduled cleanup</h2>
<p class="section-copy">Cleanup runs every {escape(settings.cleanup_interval_seconds)} seconds. It removes unverified domain claims after {escape(settings.cleanup_challenge_hours)} hours and never-used generated hostnames after {escape(settings.cleanup_unused_account_hours)} hours.</p>
<p class="section-copy">The scheduler state is stored in SQLite. Multiple app workers coordinate with a database lease, so only one worker runs a due cleanup task.</p>
</div>
<form method="post" action="{safe_admin_path}/cleanup" class="col-lg-5 card card-body gap-3" aria-label="Run cleanup now">
<input type="hidden" name="csrf" value="{csrf}">
<button type="submit" class="btn btn-primary rounded-pill">Run cleanup now</button>
</form>
</div>
</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">Hostnames</p>
<h2>Active credentials</h2>
<p class="section-copy mb-0">Showing up to {escape(len(accounts))} of {accounts_count} hostnames.</p>
</div>
</div>
{_admin_table_toolbar("hostnames", "Search hostnames")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead>
<tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th>Links</th><th></th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{account_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">
<div>
<p class="eyebrow">Domain claims</p>
<h2>Verification lifecycle</h2>
<p class="section-copy mb-0">Showing up to {escape(len(domain_challenges))} of {domain_count} claims.</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>Domain</th><th>Owner dashboard</th><th>Status</th><th>Created</th><th>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">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Scheduler state</h2>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll mb-4">
<table class="table mb-0">
<thead><tr><th>Task</th><th>Status</th><th>Next run</th><th>Lease expires</th><th>Last finished</th><th>Last error</th></tr></thead>
<tbody>{scheduled_task_rows}</tbody>
</table>
</div>
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Cleanup</p>
<h2>Recent runs</h2>
<p class="section-copy mb-0">Showing up to {escape(len(cleanup_runs))} of {cleanup_count} runs.</p>
</div>
</div>
{_admin_table_toolbar("cleanup-runs", "Search cleanup runs")}
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Domain claims removed</th><th>Unused hostnames removed</th></tr></thead>
<tbody>{cleanup_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{cleanup_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">
<div>
<p class="eyebrow">Updates</p>
<h2>Recent events</h2>
<p class="section-copy mb-0">Showing up to {escape(len(update_events))} of {event_count} event rows. Repeated unchanged polls are aggregated.</p>
</div>
</div>
{_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>Time</th><th>Domain</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{event_rows}</tbody>
</table>
</div>
{_admin_table_pagination()}
{event_pager}
</div>
</section>
</main>
"""),
)
def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: int, total: int) -> SafeHtml:
if total <= limit:
return trusted("")
previous_offset = max(0, offset - limit)
next_offset = offset + limit
previous_disabled = " disabled" if offset <= 0 else ""
next_disabled = " disabled" if next_offset >= total else ""
previous_href = attr(f"{admin_path}?{urlencode({offset_name: previous_offset})}")
next_href = attr(f"{admin_path}?{urlencode({offset_name: next_offset})}")
start = min(offset + 1, total)
end = min(offset + limit, total)
return trusted(f"""
<nav class="d-flex align-items-center justify-content-end gap-2 mt-3" aria-label="Server-side table pages">
<span class="small text-secondary">{escape(start)}-{escape(end)} of {escape(total)}</span>
<a class="btn btn-sm btn-light rounded-pill{previous_disabled}" href="{previous_href}" aria-disabled="{str(offset <= 0).lower()}">Previous server page</a>
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next server page</a>
</nav>
""")
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
hostname = escape(account["hostname"])
username = escape(account["username"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
safe_admin_path = attr(admin_path)
return f"""
<tr>
<td>{hostname}</td><td>{username}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/rotate" class="d-flex gap-2 flex-wrap">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button name="action" value="update" class="btn btn-sm btn-light px-3" title="Rotate update URL" aria-label="Rotate update URL for {hostname}">Update URL</button>
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Rotate management link" aria-label="Rotate management link for {hostname}">Manage link</button>
<button name="action" value="password" class="btn btn-sm btn-light px-3" title="Rotate router password" aria-label="Rotate router password for {hostname}">Password</button>
</form>
</td>
<td>
<form method="post" action="{safe_admin_path}/accounts/delete">
<input type="hidden" name="csrf" value="{attr(csrf)}">
<input type="hidden" name="hostname" value="{hostname}">
<button class="btn btn-sm btn-light px-2 text-danger" title="Delete hostname" aria-label="Delete {hostname}">x</button>
</form>
</td>
</tr>
"""
def _admin_table_toolbar(table_id: str, search_label: str) -> str:
safe_id = attr(table_id)
safe_label = attr(search_label)
return f"""
<div class="admin-table-shell mt-3" data-admin-table>
<div class="d-flex align-items-center justify-content-between gap-3 flex-column flex-md-row mb-3">
<label class="w-100 mb-0" for="{safe_id}-search">
<span class="visually-hidden">{safe_label}</span>
<input id="{safe_id}-search" class="form-control" type="search" placeholder="{safe_label}" data-table-search autocomplete="off">
</label>
<div class="d-flex align-items-center gap-2 flex-shrink-0">
<span class="small text-secondary text-nowrap" data-table-count>0-0 of 0</span>
<label class="visually-hidden" for="{safe_id}-page-size">Rows per page</label>
<select id="{safe_id}-page-size" class="form-select form-select-sm table-page-size" data-table-page-size>
<option value="10">10</option>
<option value="25" selected>25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
"""
def _admin_table_pagination() -> str:
return """
<nav class="mt-3" aria-label="Table pagination">
<ul class="pagination pagination-sm justify-content-end mb-0" data-table-pagination></ul>
</nav>
</div>
"""
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 "-")
status = escape(event.get("status") or "-")
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
return f"<tr><td>{created}</td><td>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
+377
View File
@@ -0,0 +1,377 @@
from __future__ import annotations
from ..ddns_models import DdnsSettings
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
_copy_button,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_page,
_secret_notice,
_top_nav,
)
from .templates_public_sections import _credentials_panel
def _render_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
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 = {
"hostname": str(account["hostname"]),
"username": str(account["username"]),
"password": "",
"update_slug": str(account["update_slug"]),
"management_slug": management_slug,
}
update_url = service.fritz_update_url(account_for_url)
hostname = escape(account["hostname"])
username = escape(account["username"])
ipv4 = escape(account.get("ipv4") or "-")
ipv6 = escape(account.get("ipv6") or "-")
updated = escape(account.get("updated_at") or "Never")
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated")
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
manual_commands = _manual_update_commands(update_url)
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)) or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
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",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Magic management</p>
<h1>{hostname}</h1>
<p class="lead">Anyone with this link can manage or delete this hostname. Keep it private.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Back home</a>
</div>
</section>
{message_html}
{rotated_credentials}
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<h2>Router update settings</h2>
<p class="section-copy">Use the Update-URL directly if your router only has one URL field. Separate Benutzername and Kennwort values are optional compatibility fields. If you use your own DNS name, point it 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 complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy CNAME record", cname_record)}
</div>
</div>
<div class="col-lg-6 d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
</div>
</div>
{_manual_update_panel(manual_commands)}
<div class="card card-body gap-3 mt-4">
<div>
<p class="eyebrow">Actions</p>
<h3>Rotate dashboard secrets</h3>
<p class="section-copy">Rotate the URL used by routers, or issue a new router password for compatibility clients. Update your router after rotating.</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="update">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate Update-URL</button>
</form>
<form method="post" action="/m/{attr(management_slug)}/rotate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<input type="hidden" name="action" value="password">
<button class="btn btn-outline-primary rounded-pill" type="submit">Rotate router credentials</button>
</form>
</div>
</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">
<div>
<p class="eyebrow">Current address</p>
<h2>Status</h2>
</div>
<div class="table-responsive status-table-shell">
<table class="table status-table mb-0">
<tbody>
<tr><th>IPv4</th><td>{ipv4}</td></tr>
<tr><th>IPv6</th><td>{ipv6}</td></tr>
<tr><th>Updated</th><td>{updated}</td></tr>
</tbody>
</table>
</div>
</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">History</p>
<h2>Router updates</h2>
<p class="section-copy">Recent IP changes and failed DNS publishes for this hostname.</p>
</div>
</div>
<div class="table-responsive border rounded admin-table-scroll">
<table class="table mb-0">
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Count</th><th>Detail</th></tr></thead>
<tbody>{history_rows}</tbody>
</table>
</div>
</div>
</section>
<section class="section danger-section">
<div class="container d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row">
<div>
<p class="eyebrow">Danger zone</p>
<h2>Delete hostname</h2>
<p class="section-copy">This removes the hostname and DNS records. The router update URL will stop working.</p>
</div>
<form method="post" action="/m/{attr(management_slug)}/delete">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<button class="btn btn-outline-danger rounded-pill" type="submit">Delete hostname</button>
</form>
</div>
</section>
</main>
"""),
)
def _management_message(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
password_status = "Enabled" if password_enabled else "Not enabled"
password_action = "Change dashboard password" if password_enabled else "Set dashboard password"
remove_form = ""
if password_enabled:
remove_form = f"""
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password/remove">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<label class="form-label" for="dashboard-remove-current-password">Current dashboard password</label>
<input class="form-control" id="dashboard-remove-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
<button class="btn btn-outline-danger rounded-pill" type="submit">Remove dashboard password</button>
</form>
"""
current_password_field = ""
if password_enabled:
current_password_field = """
<div>
<label class="form-label" for="dashboard-current-password">Current dashboard password</label>
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
return trusted(
f"""
<section class="section">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<p class="eyebrow">Dashboard password</p>
<h2>Password protection</h2>
<p class="section-copy">Status: {escape(password_status)}. This protects this management dashboard only. Router update URLs continue to work normally.</p>
</div>
<div class="col-lg-7 d-grid gap-3">
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/password">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
{current_password_field}
<div>
<label class="form-label" for="dashboard-password">{escape(password_action)}</label>
<input class="form-control" id="dashboard-password" name="dashboard_password" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div>
<label class="form-label" for="dashboard-password-confirm">Confirm password</label>
<input class="form-control" id="dashboard-password-confirm" name="dashboard_password_confirm" type="password" autocomplete="new-password" minlength="8" required>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary rounded-pill" type="submit">{escape(password_action)}</button>
</div>
</form>
{remove_form}
</div>
</div>
</div>
</section>
"""
)
def _render_management_unlock_page(
management_slug: str,
account: dict[str, str | int | None],
csrf_token: str,
message: str | None = None,
) -> str:
hostname = escape(account["hostname"])
message_html = ""
if message:
message_html = f'<div class="alert alert-warning" role="alert">{escape(message)}</div>'
return _page(
"Unlock KarlDNS management",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Dashboard locked</p>
<h1>{hostname}</h1>
<p class="lead">Enter the dashboard password to manage this hostname.</p>
</div>
</section>
<section class="section">
<div class="container narrow">
{message_html}
<form class="card card-body gap-3" method="post" action="/m/{attr(management_slug)}/unlock">
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
<div>
<label class="form-label" for="dashboard-unlock-password">Dashboard password</label>
<input class="form-control" id="dashboard-unlock-password" name="dashboard_password" type="password" autocomplete="current-password" required autofocus>
</div>
<label class="form-check d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" name="stay_unlocked" value="1">
<span class="form-check-label">Stay unlocked for 30 days on this browser</span>
</label>
<button class="btn btn-primary rounded-pill" type="submit">Unlock dashboard</button>
</form>
</div>
</section>
</main>
"""),
)
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>Claim a domain</h2>
<p class="section-copy">Create a TXT challenge, verify it from this dashboard, then create router credentials for a hostname under that verified 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 domain 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 verification"
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. Another dashboard cannot verify or use it.</p>
</div>
<div class="alert alert-info mb-0 py-2 small">TXT name and value are public DNS records. The claim secret and private claim link are secrets.</div>
<div class="d-flex gap-2 flex-wrap">
{_copy_button("Copy TXT record", 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 claim</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>Custom 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 custom 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 "-")
ipv4 = escape(event.get("ipv4") or "-")
ipv6 = escape(event.get("ipv6") or "-")
count = escape(event.get("count") or 1)
detail = escape(event.get("detail") or "-")
return f"<tr><td>{created}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{count}</td><td>{detail}</td></tr>"
+134
View File
@@ -0,0 +1,134 @@
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
def _render_public_home(
service: DdnsService,
settings: DdnsSettings,
created_account: dict[str, str] | None,
message: str | None,
challenge: dict[str, str | None] | None = None,
) -> str:
suffix = escape(settings.hostname_suffix or "your DynDNS domain")
demo_update_url = escape(f"{settings.public_base_url.rstrip('/')}/u/<secret>?myip=<ipaddr>")
created_html = _credentials_panel(service, created_account)
message_html = _message_band(message) if message and not challenge else ""
challenge_html = _custom_domain_flow(service, challenge, message)
return _page(
"KarlDNS",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band">
<div class="container text-center d-grid justify-items-center gap-3">
<img class="hero-logo" src="/logo.png" alt="" width="80" height="80">
<h1>KarlDNS for home routers.</h1>
<div class="install-pill mx-auto">
<code>{demo_update_url}</code>
</div>
<p class="lead">Generate one secret update URL under {suffix}. Paste it into your router, script it with curl, or point your own subdomain at it with a CNAME.</p>
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
<a class="btn btn-primary rounded-pill" href="#create">Generate hostname</a>
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">See curl flow</a>
</div>
</div>
</section>
{message_html}
{created_html}
<section class="section" id="create">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Choose your hostname</p>
<h2>No account. No login.</h2>
<p class="section-copy mx-auto">Use a generated KarlDNS hostname immediately, or verify your own domain with a DNS TXT record.</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">Use our domain</p>
<h3>Generate a KarlDNS hostname</h3>
<p class="section-copy mb-0">KarlDNS generates the hostname, secret update URL, and optional router compatibility credentials automatically.</p>
<form method="post" action="/magic" class="d-grid gap-3" aria-label="Create a generated DynDNS hostname">
<button type="submit" class="btn btn-primary rounded-pill">Generate hostname</button>
</form>
</div>
</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 DNS ownership</h3>
<p class="section-copy mb-0">Add one TXT record so KarlDNS can confirm you control the domain before it publishes A/AAAA records there.</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 challenge</button>
</form>
</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>
</div>
</section>
<section class="section" id="manual-updates">
<div class="container narrow text-center">
<h2>Update from anything that can send GET.</h2>
<p class="lead mx-auto mt-3">Use FRITZ!Box placeholders, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, or your own automation.</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>
</div>
</section>
{_linux_installer_section()}
{challenge_html}
</main>
"""),
)
def _render_privacy_page() -> str:
return _page(
"Legal and Privacy",
trusted(f"""
<main id="main">
{_top_nav()}
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Legal and privacy</p>
<h1>KarlDNS keeps accountless DynDNS simple.</h1>
<p class="lead">This page explains the basic data handled by this self-hosted service. Configure your own imprint, controller details, and jurisdiction-specific terms before offering it publicly.</p>
</div>
</section>
<section class="section">
<div class="container narrow d-grid gap-4">
<div>
<h2>Data processed</h2>
<p class="section-copy">KarlDNS stores generated hostnames, public DNS target names, update slugs, hashed management tokens, hashed router passwords, DNS TXT verification challenges, IP update history, timestamps, and operational events needed to run Dynamic DNS.</p>
</div>
<div>
<h2>Secrets</h2>
<p class="section-copy">Private management links, update URLs, router passwords, and one-box update URLs are bearer secrets. Anyone with them can update or manage the related hostname. Passwords and management lookup values are not stored in recoverable plaintext.</p>
</div>
<div>
<h2>Logs and retention</h2>
<p class="section-copy">The service keeps recent update events for troubleshooting and runs cleanup jobs for abandoned setup flows and unused hostnames. Server and reverse-proxy logs may also contain IP addresses and request metadata depending on your deployment.</p>
</div>
<div>
<h2>Contact</h2>
<p class="section-copy">For the public KarlDNS instance, contact Karl through <a href="https://karl.fail" rel="noopener">karl.fail</a> or <a href="https://karlcom.de" rel="noopener">karlcom.de</a>.</p>
</div>
</div>
</section>
</main>
"""),
)
@@ -0,0 +1,191 @@
from __future__ import annotations
from ..ddns_service import DdnsService
from .html_utils import SafeHtml, attr, escape, trusted
from .templates import (
_copy_button,
_copy_row,
_fritzbox_fields,
_manual_update_commands,
_manual_update_panel,
_one_time_secret_notice,
_secret_notice,
)
def _linux_installer_section() -> SafeHtml:
system_install = "curl -fsSL https://karldns.de/install.sh | sudo sh"
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">
<div class="container narrow">
<div class="text-center mb-4">
<p class="eyebrow">Linux cron updater</p>
<h2>Install and uninstall should be boring.</h2>
<p class="section-copy mx-auto">For a Linux server, Proxmox host, NAS, or home-lab box that is always on, the installer creates a hostname if needed, installs the updater, schedules cron, runs the first update, and prints the private dashboard URL.</p>
</div>
<div class="card card-body gap-3">
<div class="terminal-card">
<code>{escape(system_install)}</code>
<code>{escape(system_uninstall)}</code>
</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)}
</div>
<div class="alert alert-info my-0 py-2 small" role="note">
Uninstall keeps the private config by default because it may contain your only local copy of the update and dashboard URLs. Use <code>--delete-config</code> for a clean wipe.
</div>
<div>
<h3>Without sudo</h3>
<p class="section-copy">User mode writes to <code>~/.local/bin</code>, <code>~/.config/karldns</code>, and your user crontab.</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 when it has propagated.")
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 DNS records. The private claim link is secret.</div>
{_copy_button("Copy TXT record", 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">Direct custom-domain publishing</p>
<h2>Verify DNS ownership</h2>
<p class="section-copy mx-auto">Use this only when KarlDNS should publish A/AAAA records directly inside your domain. If you just want your own subdomain, generate a hostname above and create a CNAME to it.</p>
</div>
<div class="row g-3 mt-4 justify-content-center">
{challenge_html}
{create_form}
</div>
</div>
</section>
"""
)
def _credentials_panel(service: DdnsService, created_account: dict[str, str] | None) -> SafeHtml:
if not created_account:
return trusted("")
hostname = created_account["hostname"]
username = created_account["username"]
password = created_account["password"]
update_url = service.fritz_update_url(created_account)
credentialed_update_url = service.credentialed_update_url(created_account)
management_url = service.magic_management_url(created_account)
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, password, management_url)
manual_commands = _manual_update_commands(update_url)
return trusted(
f"""
<section class="section success-section">
<div class="container">
<p class="eyebrow">Hostname generated</p>
<h2>{escape(hostname)}</h2>
<p class="section-copy">Use the Update-URL directly. The separate Benutzername and Kennwort values are optional compatibility fields for routers that require them.</p>
{_one_time_secret_notice()}
{_secret_notice()}
<div class="d-flex gap-2 flex-wrap my-3">
{_copy_button("Copy complete FRITZ!Box fields", fritzbox_fields)}
{_copy_button("Copy one-box update URL", credentialed_update_url)}
{_copy_button("Copy CNAME record", cname_record)}
</div>
<div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("One-box update URL:", credentialed_update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername (optional):", username, "Public")}
{_copy_row("Kennwort (optional):", password, "Secret")}
{_copy_row("Private status page:", management_url, "Secret")}
</div>
{_manual_update_panel(manual_commands)}
</div>
</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 router 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 compatibility credentials are generated automatically after creation.</p>
<button type="submit" class="btn btn-primary rounded-pill">Create credentials</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 router hostname</h3>
<p class="small text-secondary mb-0 mt-2">This unlocks after the TXT record is visible in public DNS.</p>
</div>
</div>
"""
)