Prepare KarlDNS production deployment
No files matched your search
@@ -0,0 +1,19 @@
|
||||
"""Self-hosted DynDNS service for FRITZ!Box and compatible routers."""
|
||||
|
||||
from .app import make_app
|
||||
from .dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
|
||||
from .models import UpdateResult
|
||||
from .settings import DdnsSettings
|
||||
from .store import DdnsStore
|
||||
|
||||
__all__ = [
|
||||
"DdnsSettings",
|
||||
"DdnsStore",
|
||||
"DnsConfigurationError",
|
||||
"DnsProviderError",
|
||||
"DnsReconciliationError",
|
||||
"UpdateResult",
|
||||
"delete_dns_records",
|
||||
"make_app",
|
||||
"publish_dns",
|
||||
]
|
||||
@@ -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 .dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
|
||||
from .models import UpdateResult
|
||||
from .routers import admin, api, assets, management, public, updates
|
||||
from .services.ddns import DdnsService
|
||||
from .settings import DdnsSettings
|
||||
from .store import DdnsStore
|
||||
from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware
|
||||
|
||||
__all__ = [
|
||||
"DdnsSettings",
|
||||
"DdnsStore",
|
||||
"DnsConfigurationError",
|
||||
"DnsProviderError",
|
||||
"DnsReconciliationError",
|
||||
"UpdateResult",
|
||||
"delete_dns_records",
|
||||
"make_app",
|
||||
"publish_dns",
|
||||
]
|
||||
|
||||
|
||||
OPENAPI_TAGS = [
|
||||
{
|
||||
"name": "hostnames",
|
||||
"description": "Create KarlDNS hostnames that can be used directly or as CNAME targets.",
|
||||
},
|
||||
{
|
||||
"name": "router",
|
||||
"description": "Router-facing registration and IP update endpoints for simple URL clients.",
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Operator API endpoints protected by the same admin HTTP Basic auth as the web admin console.",
|
||||
},
|
||||
]
|
||||
|
||||
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 and publish A/AAAA records "
|
||||
"for KarlDNS-managed hostnames through Cloudflare or RFC 2136."
|
||||
),
|
||||
version="0.1.0",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
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_unused_account_hours,
|
||||
settings.cleanup_event_history_hours,
|
||||
settings.cleanup_pending_account_hours,
|
||||
)
|
||||
except Exception as exc:
|
||||
LOGGER.exception("Scheduled cleanup failed")
|
||||
await run_in_threadpool(store.increment_metric, "cleanup_errors_total")
|
||||
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.increment_metric, "cleanup_success_total")
|
||||
await run_in_threadpool(store.optimize)
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1 @@
|
||||
:root{--bs-body-color:#000;--bs-body-bg:#fff;--bs-border-color:#e5e5e5;--bs-secondary-color:#737373}[data-bs-theme="dark"]{--bs-body-color:#f5f5f5;--bs-body-bg:#0f0f0f;--bs-border-color:rgba(255,255,255,.16);--bs-secondary-color:#a3a3a3}html{-webkit-text-size-adjust:100%}body{margin:0}button,input,select{margin:0;font:inherit}button,select{text-transform:none}button:not(:disabled),[type="button"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}a{color:inherit}img,svg{vertical-align:middle}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}pre{margin:0}.container{width:100%;margin-right:auto;margin-left:auto;padding-right:12px;padding-left:12px}.row{display:flex;flex-wrap:wrap;margin-right:calc(-.5 * var(--bs-gutter-x,0));margin-left:calc(-.5 * var(--bs-gutter-x,0));margin-top:calc(-1 * var(--bs-gutter-y,0))}.row>*{width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x,0) * .5);padding-left:calc(var(--bs-gutter-x,0) * .5);margin-top:var(--bs-gutter-y,0)}.col-6{flex:0 0 auto;width:50%}.col-12{flex:0 0 auto;width:100%}@media (min-width:768px){.col-md-6{flex:0 0 auto;width:50%}.align-items-md-center{align-items:center!important}.flex-md-row{flex-direction:row!important}}@media (min-width:992px){.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.333333%}.col-lg-5{flex:0 0 auto;width:41.666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.333333%}.flex-lg-row{flex-direction:row!important}}@media (min-width:1200px){.col-xl-3{flex:0 0 auto;width:25%}}.g-3{--bs-gutter-x:16px;--bs-gutter-y:16px}.g-4{--bs-gutter-x:24px;--bs-gutter-y:24px}.d-flex{display:flex!important}.d-grid{display:grid!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-wrap{flex-wrap:wrap!important}.flex-shrink-0{flex-shrink:0!important}.align-items-center{align-items:center!important}.align-items-start{align-items:flex-start!important}.align-items-stretch{align-items:stretch!important}.align-self-start{align-self:flex-start!important}.justify-content-between{justify-content:space-between!important}.justify-content-center{justify-content:center!important}.justify-content-end{justify-content:flex-end!important}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.gap-sm-3{gap:16px!important}}.gap-2{gap:8px!important}.gap-3{gap:16px!important}.gap-4{gap:24px!important}.m-0{margin:0!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.ms-auto{margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-3{margin-top:16px!important;margin-bottom:16px!important}.mt-1{margin-top:4px!important}.mt-2{margin-top:8px!important}.mt-3{margin-top:16px!important}.mt-4{margin-top:24px!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:4px!important}.mb-2{margin-bottom:8px!important}.mb-3{margin-bottom:16px!important}.mb-4{margin-bottom:24px!important}.p-0{padding:0!important}.p-4{padding:24px!important}.px-0{padding-right:0!important;padding-left:0!important}.px-2{padding-right:8px!important;padding-left:8px!important}.px-3{padding-right:16px!important;padding-left:16px!important}.py-2{padding-top:8px!important;padding-bottom:8px!important}.w-100{width:100%!important}.h-100{height:100%!important}.text-center{text-align:center!important}.text-start{text-align:left!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-secondary{color:var(--bs-secondary-color)!important}.text-danger{color:var(--rp-danger)!important}.small{font-size:.875em!important}.h5{font-size:1.25rem}.opacity-75{opacity:.75!important}.rounded{border-radius:12px!important}.rounded-0{border-radius:0!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:9999px!important}.border{border:1px solid var(--bs-border-color)!important}.border-top{border-top:1px solid var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-end-0{border-right:0!important}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:5px;padding-bottom:5px;margin-right:16px;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-text{padding-top:8px;padding-bottom:8px}.nav-link{display:block;padding:8px;text-decoration:none}.btn{--bs-btn-color:#000;--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-btn-color);--bs-btn-hover-bg:var(--bs-btn-bg);--bs-btn-hover-border-color:var(--bs-btn-border-color);--bs-btn-active-color:var(--bs-btn-hover-color);--bs-btn-active-bg:var(--bs-btn-hover-bg);--bs-btn-active-border-color:var(--bs-btn-hover-border-color);color:var(--bs-btn-color);background-color:Line truncated
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-book-open"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-briefcase"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
<rect width="20" height="14" x="2" y="6" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 399 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-check"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-clipboard"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-code"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m16 18 6-6-6-6" />
|
||||
<path d="m8 6-6 6 6 6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-ellipsis-vertical"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-moon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 409 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-newspaper"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 18h-5" />
|
||||
<path d="M18 14h-8" />
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2" />
|
||||
<rect width="8" height="4" x="10" y="6" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-shield-check"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.16.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sun"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 559 B |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .app import make_app
|
||||
from .settings import DdnsSettings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="karldns")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
render_url = subparsers.add_parser("render-fritz-url", help="Print a FRITZ!Box DynDNS update URL.")
|
||||
render_url.add_argument("--base-url", required=True, help="Public KarlDNS URL.")
|
||||
render_url.add_argument("--update-secret", required=True, help="Private router update secret.")
|
||||
render_url.add_argument("--include-ipv6", action="store_true")
|
||||
|
||||
serve = subparsers.add_parser("serve", help="Run the KarlDNS web 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 redacted request logs. Raw web-server access logs stay off because private URLs are secrets.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "render-fritz-url":
|
||||
print(_render_fritz_url(args.base_url, args.update_secret, args.include_ipv6))
|
||||
elif args.command == "serve":
|
||||
import uvicorn
|
||||
|
||||
settings = DdnsSettings.from_env()
|
||||
if not settings.admin_password:
|
||||
raise SystemExit("Set DDNS_ADMIN_PASSWORD before starting KarlDNS.")
|
||||
if args.access_log:
|
||||
settings = settings.model_copy(update={"redacted_access_log": True})
|
||||
uvicorn.run(make_app(settings), host=args.host, port=args.port, access_log=False)
|
||||
|
||||
|
||||
def _render_fritz_url(base_url: str, update_secret: str, include_ipv6: bool) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
url = f"{base}/api/v1/router/update/{update_secret}?myip=<ipaddr>"
|
||||
if include_ipv6:
|
||||
url += "&myipv6=<ip6addr>"
|
||||
return url
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .settings import DdnsSettings
|
||||
|
||||
|
||||
def settings_from_env() -> DdnsSettings:
|
||||
return DdnsSettings(
|
||||
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
|
||||
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", ""),
|
||||
admin_path=os.getenv("DDNS_ADMIN_PATH", "/admin"),
|
||||
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", ""),
|
||||
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_pending_account_hours=_env_int("DDNS_CLEANUP_PENDING_ACCOUNT_HOURS", 24),
|
||||
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,
|
||||
),
|
||||
dashboard_cookie_secret=os.getenv("DDNS_DASHBOARD_COOKIE_SECRET", ""),
|
||||
redacted_access_log=_env_flag("DDNS_REDACTED_ACCESS_LOG"),
|
||||
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
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from .dns import DnsProviderError
|
||||
from .security import credentials_from_basic_auth
|
||||
from .services.ddns import BadIpError, DdnsService
|
||||
from .settings import DdnsSettings
|
||||
from .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_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)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
DnsConfigurationError,
|
||||
DnsProviderError,
|
||||
DnsReconciliationError,
|
||||
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",
|
||||
"DnsReconciliationError",
|
||||
"delete_cloudflare_records",
|
||||
"delete_dns_records",
|
||||
"delete_rfc2136_records",
|
||||
"dns_backend_configured",
|
||||
"publish_cloudflare",
|
||||
"publish_dns",
|
||||
"publish_rfc2136",
|
||||
"validate_dns_provider_settings",
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import UpdateResult
|
||||
from ..settings import DdnsSettings
|
||||
|
||||
SUPPORTED_DNS_PROVIDERS = {"", "cloudflare", "rfc2136"}
|
||||
|
||||
|
||||
class DnsProviderError(RuntimeError):
|
||||
"""Raised for expected DNS provider publish/delete failures."""
|
||||
|
||||
|
||||
class DnsReconciliationError(DnsProviderError):
|
||||
"""Raised when provider read-back does not match the requested DNS state."""
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from ..models import UpdateResult
|
||||
from ..settings import DdnsSettings
|
||||
from .base import DnsProviderError, DnsReconciliationError
|
||||
|
||||
|
||||
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)
|
||||
_cloudflare_reconcile_record(settings, hostname, record_type, value)
|
||||
|
||||
|
||||
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_reconcile_record(settings: DdnsSettings, hostname: str, record_type: str, value: str) -> None:
|
||||
record = _cloudflare_find_record(settings, hostname, record_type)
|
||||
if not record:
|
||||
raise DnsReconciliationError(f"Cloudflare {record_type} record was not visible after publish")
|
||||
if record.get("content") != value:
|
||||
raise DnsReconciliationError(f"Cloudflare {record_type} record did not match requested content after publish")
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import UpdateResult
|
||||
from ..settings import DdnsSettings
|
||||
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)}")
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
DNS_DELETE_FAILED = "dns_delete_failed"
|
||||
DNS_FAILED = "dns_failed"
|
||||
DNS_RECONCILE_FAILED = "dns_reconcile_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_HOSTNAME_DELETED = "dashboard_hostname_deleted"
|
||||
DASHBOARD_HOSTNAME_ACTIVATED = "dashboard_hostname_activated"
|
||||
|
||||
ADMIN_HOSTNAME_DELETED = "admin_hostname_deleted"
|
||||
ADMIN_UPDATE_URL_ROTATED = "admin_update_url_rotated"
|
||||
ADMIN_MANAGEMENT_URL_ROTATED = "admin_management_url_rotated"
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UpdateResult:
|
||||
hostname: str
|
||||
ipv4: str | None
|
||||
ipv6: str | None
|
||||
changed: bool
|
||||
update_ipv4: bool = True
|
||||
update_ipv6: bool = True
|
||||
|
||||
@property
|
||||
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}"
|
||||
@@ -0,0 +1,176 @@
|
||||
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 ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
|
||||
from ..dns import DnsProviderError
|
||||
from ..event_statuses import (
|
||||
ADMIN_HOSTNAME_DELETED,
|
||||
ADMIN_MANAGEMENT_URL_ROTATED,
|
||||
ADMIN_UPDATE_URL_ROTATED,
|
||||
)
|
||||
from ..security import client_ip, require_admin_csrf
|
||||
from ..services.ddns import normalize_hostname
|
||||
from ..web.components import _copy_row
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
|
||||
|
||||
ADMIN_TABLE_LIMIT = 100
|
||||
ADMIN_JSON_OFFSET_CAP = 10_000
|
||||
|
||||
|
||||
def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
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_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
|
||||
store.list_scheduled_tasks(),
|
||||
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
|
||||
store.get_service_metrics(),
|
||||
{
|
||||
"accounts": store.count_accounts(),
|
||||
"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),
|
||||
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,
|
||||
"cleanup_runs": cleanup_runs_offset,
|
||||
"update_events": update_events_offset,
|
||||
},
|
||||
)
|
||||
|
||||
@router.post(f"{admin_path}/hostnames/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}/hostnames/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 changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated), "Secret")]) 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,
|
||||
"dashboard link rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")]) if rotated else None
|
||||
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_unused_account_hours,
|
||||
settings.cleanup_event_history_hours,
|
||||
settings.cleanup_pending_account_hours,
|
||||
)
|
||||
notice = _admin_secret_panel(
|
||||
"Cleanup done",
|
||||
[
|
||||
_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)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = make_router()
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..dependencies import ServiceDep, StoreDep, authenticate_admin
|
||||
from ..dns import DnsProviderError
|
||||
from ..event_statuses import DASHBOARD_UPDATE_URL_ROTATED
|
||||
from ..schemas import (
|
||||
AdminDashboardResponse,
|
||||
AdminEventsResponse,
|
||||
AdminHostnamesResponse,
|
||||
AdminRecordsResponse,
|
||||
AdminTotals,
|
||||
ErrorResponse,
|
||||
HostnameCreateResponse,
|
||||
HostnameRotateUpdateResponse,
|
||||
HostnameStatusResponse,
|
||||
_admin_hostname,
|
||||
_admin_metrics,
|
||||
_create_response,
|
||||
_hostname_rotate_update_response,
|
||||
_hostname_status,
|
||||
)
|
||||
from ..security import client_ip
|
||||
from .path_params import ManagementSlugPath
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
ADMIN_API_LIMIT_CAP = 500
|
||||
ADMIN_API_OFFSET_CAP = 10_000
|
||||
HOSTNAME_HISTORY_LIMIT_CAP = 100
|
||||
|
||||
|
||||
@router.post(
|
||||
"/hostnames",
|
||||
response_model=HostnameCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a KarlDNS hostname",
|
||||
tags=["hostnames"],
|
||||
responses={500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
|
||||
account = await run_in_threadpool(service.create_managed_account)
|
||||
return _create_response(service, account)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/hostnames/{dashboard_secret}",
|
||||
response_model=HostnameStatusResponse,
|
||||
summary="Read a hostname by private dashboard link",
|
||||
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
|
||||
tags=["hostnames"],
|
||||
responses={404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def read_hostname(
|
||||
dashboard_secret: ManagementSlugPath,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
history_limit: int = Query(10, ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP),
|
||||
) -> HostnameStatusResponse:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
account["management_slug"] = dashboard_secret
|
||||
history = await run_in_threadpool(store.list_update_events_for_hostname, str(account["hostname"]), history_limit) if history_limit else []
|
||||
return _hostname_status(service, account, history)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/hostnames/{dashboard_secret}/rotate-update-url",
|
||||
response_model=HostnameRotateUpdateResponse,
|
||||
summary="Rotate a hostname update URL",
|
||||
description="Uses the private dashboard secret. The old router update URL stops working immediately.",
|
||||
tags=["hostnames"],
|
||||
responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def rotate_hostname_update_url(
|
||||
dashboard_secret: ManagementSlugPath,
|
||||
request: Request,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> HostnameRotateUpdateResponse:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
if not account["accepted_at"]:
|
||||
raise HTTPException(status_code=400, detail="register hostname before rotating update URL")
|
||||
hostname = str(account["hostname"])
|
||||
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
|
||||
if not rotated:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_UPDATE_URL_ROTATED,
|
||||
"router update URL rotated by API",
|
||||
client_ip(request, service.settings),
|
||||
)
|
||||
return _hostname_rotate_update_response(service, rotated, dashboard_secret)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/hostnames/{dashboard_secret}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a hostname by private dashboard link",
|
||||
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
|
||||
tags=["hostnames"],
|
||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> Response:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
|
||||
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.get(
|
||||
"/admin/dashboard",
|
||||
response_model=AdminDashboardResponse,
|
||||
summary="Read operator dashboard summary",
|
||||
description="Requires admin HTTP Basic auth. Returns the same high-level data shown in the operator console.",
|
||||
tags=["admin"],
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def read_admin(
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(25, ge=1, le=ADMIN_API_LIMIT_CAP),
|
||||
) -> AdminDashboardResponse:
|
||||
metrics = await run_in_threadpool(store.get_service_metrics)
|
||||
accounts = await run_in_threadpool(store.list_accounts, limit, 0)
|
||||
cleanup_runs = await run_in_threadpool(store.list_cleanup_runs, limit, 0)
|
||||
scheduled_tasks = await run_in_threadpool(store.list_scheduled_tasks)
|
||||
events = await run_in_threadpool(store.list_update_events, limit, 0)
|
||||
return AdminDashboardResponse(
|
||||
totals=AdminTotals(
|
||||
hostnames=await run_in_threadpool(store.count_accounts),
|
||||
records=await run_in_threadpool(store.count_records),
|
||||
update_events=await run_in_threadpool(store.count_update_events),
|
||||
cleanup_runs=await run_in_threadpool(store.count_cleanup_runs),
|
||||
),
|
||||
metrics=_admin_metrics(metrics),
|
||||
hostnames=[_admin_hostname(account) for account in accounts],
|
||||
update_events=events,
|
||||
cleanup_runs=cleanup_runs,
|
||||
scheduled_tasks=scheduled_tasks,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/hostnames",
|
||||
response_model=AdminHostnamesResponse,
|
||||
summary="List operator hostnames",
|
||||
description="Requires admin HTTP Basic auth.",
|
||||
tags=["admin"],
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def list_admin_hostnames(
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
|
||||
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
|
||||
) -> AdminHostnamesResponse:
|
||||
accounts = await run_in_threadpool(store.list_accounts, limit, offset)
|
||||
total = await run_in_threadpool(store.count_accounts)
|
||||
return AdminHostnamesResponse(
|
||||
items=[_admin_hostname(account) for account in accounts],
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/events",
|
||||
response_model=AdminEventsResponse,
|
||||
summary="List operator update history",
|
||||
description="Requires admin HTTP Basic auth. Supports offset pagination or keyset pagination with before.",
|
||||
tags=["admin"],
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def list_admin_events(
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
|
||||
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
|
||||
before: str | None = Query(None, max_length=64),
|
||||
) -> AdminEventsResponse:
|
||||
items = (
|
||||
await run_in_threadpool(store.list_update_events_before, limit, before)
|
||||
if before
|
||||
else await run_in_threadpool(store.list_update_events_page, limit, offset)
|
||||
)
|
||||
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
|
||||
total = await run_in_threadpool(store.count_update_events)
|
||||
return AdminEventsResponse(items=items, limit=limit, offset=offset, next_before=next_before, total=total)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/records",
|
||||
response_model=AdminRecordsResponse,
|
||||
summary="List operator DNS records",
|
||||
description="Requires admin HTTP Basic auth.",
|
||||
tags=["admin"],
|
||||
responses={401: {"model": ErrorResponse}},
|
||||
)
|
||||
async def list_admin_records(
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
|
||||
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
|
||||
) -> AdminRecordsResponse:
|
||||
items = await run_in_threadpool(store.list_records_page, limit, offset)
|
||||
total = await run_in_threadpool(store.count_records)
|
||||
return AdminRecordsResponse(items=items, limit=limit, offset=offset, total=total)
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
|
||||
from ..web.scripts import ADMIN_TABLES_JS, CLIPBOARD_JS, THEME_JS
|
||||
from ..web.styles import ADMIN_CSS, BASE_CSS
|
||||
|
||||
ASSET_DIR = Path(__file__).parents[1] / "assets"
|
||||
router = APIRouter(include_in_schema=False)
|
||||
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||
|
||||
|
||||
def _minify_css(css: str) -> str:
|
||||
return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}")
|
||||
|
||||
|
||||
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
|
||||
MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
|
||||
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
def favicon() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/bootstrap-subset.css")
|
||||
def bootstrap_subset_css() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", 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.webp")
|
||||
def logo_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-nav.webp")
|
||||
def logo_nav_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", 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("/social-preview.png")
|
||||
def social_preview_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/social-preview.webp")
|
||||
def social_preview_webp() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/site.webmanifest")
|
||||
def site_webmanifest() -> Response:
|
||||
return Response(
|
||||
"""
|
||||
{
|
||||
"name": "KarlDNS",
|
||||
"short_name": "KarlDNS",
|
||||
"description": "Private update links for routers.",
|
||||
"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("/theme.js")
|
||||
def theme_js() -> Response:
|
||||
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/clipboard.js")
|
||||
def clipboard_js() -> Response:
|
||||
return Response(CLIPBOARD_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/admin-tables.js")
|
||||
def admin_tables_js() -> Response:
|
||||
return Response(ADMIN_TABLES_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/app.css")
|
||||
def app_css() -> Response:
|
||||
return Response(MINIFIED_BASE_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/admin.css")
|
||||
def admin_css() -> Response:
|
||||
return Response(MINIFIED_ADMIN_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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/karldns-update.sh")
|
||||
def karldns_update_sh() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/karldns-update.sh",
|
||||
status_code=302,
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
||||
from ..dns import DnsProviderError
|
||||
from ..event_statuses import (
|
||||
DASHBOARD_HOSTNAME_ACTIVATED,
|
||||
DASHBOARD_HOSTNAME_DELETED,
|
||||
DASHBOARD_UPDATE_URL_ROTATED,
|
||||
)
|
||||
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
from ..services.ddns import BadIpError
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.html_utils import trusted
|
||||
from ..web.layout import _page
|
||||
from ..web.templates_management import (
|
||||
_render_management_page,
|
||||
_render_management_unlock_page,
|
||||
_render_pending_management_page,
|
||||
)
|
||||
from . import management_passwords
|
||||
from .management_shared import (
|
||||
get_active_management_account,
|
||||
get_active_management_account_async,
|
||||
has_management_access,
|
||||
locked_management_response,
|
||||
management_account_is_accepted,
|
||||
render_management_page_async,
|
||||
)
|
||||
from .path_params import ManagementSlugPath
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(management_passwords.router)
|
||||
|
||||
|
||||
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
|
||||
def manage_dashboard(
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
activated: bool = False,
|
||||
) -> str:
|
||||
account = get_active_management_account(store, management_slug)
|
||||
csrf_token = dashboard_csrf_token(management_slug, account)
|
||||
if not management_account_is_accepted(account):
|
||||
return _render_pending_management_page(settings, management_slug, account, csrf_token)
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return _render_management_unlock_page(management_slug, account, csrf_token)
|
||||
message = "Hostname registered. Add the update URL to your router." if activated else None
|
||||
return _render_management_page(service, settings, management_slug, account, csrf_token, message=message, request_ip=client_ip(request, settings))
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/activate", include_in_schema=False)
|
||||
async def activate_dashboard(
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
myip: str | None = None,
|
||||
ipaddr: str | None = None,
|
||||
myipv6: str | None = None,
|
||||
ip6addr: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
hostname = str(account["hostname"])
|
||||
if not management_account_is_accepted(account):
|
||||
try:
|
||||
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
|
||||
except BadIpError:
|
||||
return HTMLResponse(
|
||||
_render_pending_management_page(
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
"Registration needs a public source IP. Try again from your home connection or use the API with myip.",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
except DnsProviderError:
|
||||
return HTMLResponse(
|
||||
_render_pending_management_page(
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
"DNS registration failed. The hostname was not registered.",
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
account = await run_in_threadpool(store.activate_account, hostname)
|
||||
if not account:
|
||||
return _not_found()
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_HOSTNAME_ACTIVATED,
|
||||
"hostname registered",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return RedirectResponse(f"/m/{management_slug}?activated=1", status_code=303)
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def rotate_dashboard_secrets(
|
||||
management_slug: ManagementSlugPath,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not management_account_is_accepted(account):
|
||||
return HTMLResponse(
|
||||
_render_pending_management_page(settings, management_slug, account, dashboard_csrf_token(management_slug, account), "Register this hostname first."),
|
||||
status_code=400,
|
||||
)
|
||||
if not has_management_access(request, settings, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
hostname = str(account["hostname"])
|
||||
action = first_form_value(form, "action")
|
||||
request_ip = client_ip(request, settings)
|
||||
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",
|
||||
request_ip,
|
||||
)
|
||||
return await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
rotated,
|
||||
dashboard_csrf_token(management_slug, rotated),
|
||||
message="Update URL changed. Update your router now.",
|
||||
request_ip=request_ip,
|
||||
)
|
||||
raise _bad_rotation_action()
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/delete", include_in_schema=False)
|
||||
async def delete_dashboard_hostname(management_slug: ManagementSlugPath, 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, settings, 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(
|
||||
"Hostname 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")
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
||||
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
|
||||
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
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,
|
||||
)
|
||||
from .path_params import ManagementSlugPath
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
|
||||
async def unlock_dashboard(management_slug: ManagementSlugPath, 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 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: ManagementSlugPath,
|
||||
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, settings, 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 password is incorrect.",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
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="Password must be at least 8 characters.",
|
||||
request_ip=client_ip(request, settings),
|
||||
)
|
||||
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="The passwords do not match.",
|
||||
request_ip=client_ip(request, settings),
|
||||
)
|
||||
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: ManagementSlugPath,
|
||||
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, settings, 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 password is incorrect.",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..security import (
|
||||
DASHBOARD_UNLOCK_COOKIE,
|
||||
dashboard_cookie_signing_secret,
|
||||
dashboard_csrf_token,
|
||||
sign_dashboard_unlock_cookie,
|
||||
verify_dashboard_unlock_cookie,
|
||||
)
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from ..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 management_account_is_accepted(account: ManagementAccount) -> bool:
|
||||
return bool(account.get("accepted_at"))
|
||||
|
||||
|
||||
def has_management_access(request: Request, settings: DdnsSettings, 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),
|
||||
dashboard_cookie_signing_secret(settings),
|
||||
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,
|
||||
request_ip: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
return HTMLResponse(
|
||||
_render_management_page(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message=message,
|
||||
request_ip=request_ip,
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
async def management_error_response_async(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: ManagementAccount,
|
||||
message: str,
|
||||
request_ip: str | None = None,
|
||||
) -> HTMLResponse:
|
||||
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message, request_ip)
|
||||
|
||||
|
||||
async def render_management_page_async(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: ManagementAccount,
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
created_account: dict[str, str] | None = None,
|
||||
request_ip: str | None = None,
|
||||
) -> str:
|
||||
return await run_in_threadpool(
|
||||
_render_management_page,
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
csrf_token,
|
||||
message,
|
||||
created_account,
|
||||
request_ip,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
signing_secret = dashboard_cookie_signing_secret(settings)
|
||||
if not password_hash or not signing_secret:
|
||||
return
|
||||
response.set_cookie(
|
||||
DASHBOARD_UNLOCK_COOKIE,
|
||||
sign_dashboard_unlock_cookie(
|
||||
str(account["hostname"]),
|
||||
management_slug,
|
||||
str(password_hash),
|
||||
signing_secret,
|
||||
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
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path
|
||||
|
||||
TOKEN_URLSAFE_PATTERN = r"^[A-Za-z0-9_-]+$"
|
||||
TOKEN_MIN_LENGTH = 32
|
||||
TOKEN_MAX_LENGTH = 128
|
||||
|
||||
UpdateSlugPath = Annotated[
|
||||
str,
|
||||
Path(
|
||||
min_length=TOKEN_MIN_LENGTH,
|
||||
max_length=TOKEN_MAX_LENGTH,
|
||||
pattern=TOKEN_URLSAFE_PATTERN,
|
||||
description="Private update URL value.",
|
||||
),
|
||||
]
|
||||
ManagementSlugPath = Annotated[
|
||||
str,
|
||||
Path(
|
||||
min_length=TOKEN_MIN_LENGTH,
|
||||
max_length=TOKEN_MAX_LENGTH,
|
||||
pattern=TOKEN_URLSAFE_PATTERN,
|
||||
description="Private dashboard URL value.",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..dependencies import ServiceDep, SettingsDep
|
||||
from ..security import client_ip
|
||||
from ..web.forms import request_form
|
||||
from ..web.templates_public import _render_api_docs_page, _render_privacy_page, _render_public_home
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
def index(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
||||
return _render_public_home(service, settings, None, None, client_ip(request, settings))
|
||||
|
||||
|
||||
@router.post("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def create_hostname(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
||||
await request_form(request, settings)
|
||||
account = await run_in_threadpool(service.create_managed_account)
|
||||
return _render_public_home(service, settings, account, None, client_ip(request, settings))
|
||||
|
||||
|
||||
@router.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
|
||||
def healthz() -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
|
||||
def privacy(settings: SettingsDep) -> str:
|
||||
return _render_privacy_page(settings)
|
||||
|
||||
|
||||
@router.get("/docs", response_class=HTMLResponse, include_in_schema=False)
|
||||
def api_docs(request: Request, settings: SettingsDep) -> str:
|
||||
return _render_api_docs_page(settings, client_ip(request, settings))
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=PlainTextResponse, include_in_schema=False)
|
||||
def robots_txt(settings: SettingsDep) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
admin_path = settings.admin_path.rstrip("/")
|
||||
return "\n".join(
|
||||
[
|
||||
"User-agent: *",
|
||||
f"Disallow: {admin_path}/",
|
||||
"Disallow: /m/",
|
||||
"Disallow: /api/",
|
||||
"Disallow: /redoc",
|
||||
"Disallow: /openapi.json",
|
||||
f"Sitemap: {base_url}/sitemap.xml",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sitemap.xml", include_in_schema=False)
|
||||
def sitemap_xml(settings: SettingsDep) -> Response:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
body = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>{base_url}/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{base_url}/privacy</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
"""
|
||||
return Response(body, media_type="application/xml")
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..dependencies import ServiceDep, StoreDep, apply_update_response
|
||||
from ..dns import DnsProviderError
|
||||
from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED
|
||||
from ..security import client_ip
|
||||
from ..services.ddns import BadIpError
|
||||
from .path_params import ManagementSlugPath, UpdateSlugPath
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/router/register/{dashboard_secret}",
|
||||
response_class=PlainTextResponse,
|
||||
response_model=None,
|
||||
operation_id="registerHostnameFromRouterGet",
|
||||
tags=["router"],
|
||||
)
|
||||
async def register_hostname_get(
|
||||
dashboard_secret: ManagementSlugPath,
|
||||
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:
|
||||
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/router/register/{dashboard_secret}",
|
||||
response_class=PlainTextResponse,
|
||||
response_model=None,
|
||||
operation_id="registerHostnameFromRouterPost",
|
||||
tags=["router"],
|
||||
)
|
||||
async def register_hostname_post(
|
||||
dashboard_secret: ManagementSlugPath,
|
||||
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:
|
||||
return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr)
|
||||
|
||||
|
||||
async def _register_hostname(
|
||||
dashboard_secret: str,
|
||||
request: Request,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> str | PlainTextResponse:
|
||||
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
hostname = str(account["hostname"])
|
||||
if not service.hostname_is_managed(hostname):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
if not account["accepted_at"]:
|
||||
try:
|
||||
await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr)
|
||||
except BadIpError as exc:
|
||||
return PlainTextResponse(f"badip {exc}", status_code=400)
|
||||
except DnsProviderError:
|
||||
return PlainTextResponse("911 dns registration failed", status_code=500)
|
||||
activated = await run_in_threadpool(store.activate_account, hostname)
|
||||
if not activated:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_HOSTNAME_ACTIVATED,
|
||||
"hostname registered",
|
||||
client_ip(request, service.settings),
|
||||
)
|
||||
return "good registered"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/router/update/{update_slug}",
|
||||
response_class=PlainTextResponse,
|
||||
response_model=None,
|
||||
operation_id="updateHostnameFromRouterGet",
|
||||
tags=["router"],
|
||||
)
|
||||
async def update_hostname_get(
|
||||
update_slug: UpdateSlugPath,
|
||||
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:
|
||||
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/router/update/{update_slug}",
|
||||
response_class=PlainTextResponse,
|
||||
response_model=None,
|
||||
operation_id="updateHostnameFromRouterPost",
|
||||
tags=["router"],
|
||||
)
|
||||
async def update_hostname_post(
|
||||
update_slug: UpdateSlugPath,
|
||||
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:
|
||||
return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr)
|
||||
|
||||
|
||||
async def _update_hostname(
|
||||
update_slug: str,
|
||||
request: Request,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> str | PlainTextResponse:
|
||||
myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr)
|
||||
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
|
||||
if not account or account["disabled"] or not account["accepted_at"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
if not service.hostname_is_managed(str(account["hostname"])):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return await run_in_threadpool(apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr)
|
||||
|
||||
|
||||
async def _ip_params_from_request(
|
||||
request: Request,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> tuple[str | None, str | None, str | None, str | None]:
|
||||
if request.method != "POST":
|
||||
return myip, ipaddr, myipv6, ip6addr
|
||||
body_values = await _post_values(request)
|
||||
return (
|
||||
myip or body_values.get("myip"),
|
||||
ipaddr or body_values.get("ipaddr"),
|
||||
myipv6 or body_values.get("myipv6"),
|
||||
ip6addr or body_values.get("ip6addr"),
|
||||
)
|
||||
|
||||
|
||||
async def _post_values(request: Request) -> dict[str, str]:
|
||||
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
body = await request.body()
|
||||
if not body:
|
||||
return {}
|
||||
if content_type == "application/json":
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {str(key): str(value) for key, value in data.items() if isinstance(value, str)}
|
||||
if content_type == "application/x-www-form-urlencoded":
|
||||
try:
|
||||
form_body = body.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
return {}
|
||||
try:
|
||||
parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8)
|
||||
except ValueError:
|
||||
return {}
|
||||
return {key: values[0] for key, values in parsed.items() if values}
|
||||
return {}
|
||||
@@ -0,0 +1,203 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .services.ddns import DdnsService
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str = Field(examples=["not found"])
|
||||
|
||||
|
||||
class HostnameCreateResponse(BaseModel):
|
||||
hostname: str
|
||||
status: str = Field(description="Lifecycle state. Newly generated hostnames start as pending until registered.")
|
||||
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
|
||||
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
|
||||
registration_url: str = Field(description="Secret URL used to register the pending hostname after secrets are saved.")
|
||||
|
||||
|
||||
class HostnameStatusResponse(BaseModel):
|
||||
hostname: str
|
||||
status: str
|
||||
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
|
||||
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
updated_at: str | None = None
|
||||
history: list["HostnameUpdateEvent"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HostnameRotateUpdateResponse(BaseModel):
|
||||
hostname: str
|
||||
status: str
|
||||
update_url: str = Field(description="Secret replacement update URL for router DynDNS updates.")
|
||||
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
|
||||
|
||||
|
||||
class HostnameUpdateEvent(BaseModel):
|
||||
hostname: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
status: str
|
||||
detail: str
|
||||
created_at: str
|
||||
count: int = 1
|
||||
|
||||
|
||||
class AdminHostname(BaseModel):
|
||||
hostname: str
|
||||
status: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
updated_at: str | None = None
|
||||
created_at: str | None = None
|
||||
accepted_at: str | None = None
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class AdminRecord(BaseModel):
|
||||
hostname: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AdminUpdateEvent(BaseModel):
|
||||
hostname: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
status: str
|
||||
detail: str
|
||||
source_ip: str
|
||||
created_at: str
|
||||
count: int = 1
|
||||
|
||||
|
||||
class AdminCleanupRun(BaseModel):
|
||||
created_at: str
|
||||
unused_accounts_deleted: int
|
||||
|
||||
|
||||
class AdminScheduledTask(BaseModel):
|
||||
name: str
|
||||
interval_seconds: int
|
||||
next_run_at: str
|
||||
lease_owner: str | None = None
|
||||
lease_expires_at: str | None = None
|
||||
last_started_at: str | None = None
|
||||
last_finished_at: str | None = None
|
||||
last_status: str
|
||||
last_error: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AdminMetrics(BaseModel):
|
||||
values: dict[str, float]
|
||||
average_update_latency_ms: float
|
||||
|
||||
|
||||
class AdminTotals(BaseModel):
|
||||
hostnames: int
|
||||
records: int
|
||||
update_events: int
|
||||
cleanup_runs: int
|
||||
|
||||
|
||||
class AdminDashboardResponse(BaseModel):
|
||||
totals: AdminTotals
|
||||
metrics: AdminMetrics
|
||||
hostnames: list[AdminHostname]
|
||||
update_events: list[AdminUpdateEvent]
|
||||
cleanup_runs: list[AdminCleanupRun]
|
||||
scheduled_tasks: list[AdminScheduledTask]
|
||||
limit: int
|
||||
|
||||
|
||||
class AdminHostnamesResponse(BaseModel):
|
||||
items: list[AdminHostname]
|
||||
limit: int
|
||||
offset: int
|
||||
total: int
|
||||
|
||||
|
||||
class AdminRecordsResponse(BaseModel):
|
||||
items: list[AdminRecord]
|
||||
limit: int
|
||||
offset: int
|
||||
total: int
|
||||
|
||||
|
||||
class AdminEventsResponse(BaseModel):
|
||||
items: list[AdminUpdateEvent]
|
||||
limit: int
|
||||
offset: int
|
||||
next_before: str | None = None
|
||||
total: int
|
||||
|
||||
|
||||
def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameCreateResponse:
|
||||
return HostnameCreateResponse(
|
||||
hostname=account["hostname"],
|
||||
status="active" if account.get("accepted_at") else "pending",
|
||||
update_url=service.fritz_update_url(account),
|
||||
dashboard_url=service.dashboard_url(account),
|
||||
registration_url=service.registration_url(account),
|
||||
)
|
||||
|
||||
|
||||
def _hostname_status(
|
||||
service: DdnsService,
|
||||
account: dict[str, str | int | None],
|
||||
history: list[dict[str, str | int | None]] | None = None,
|
||||
) -> HostnameStatusResponse:
|
||||
account_for_url = {"update_slug": str(account["update_slug"])}
|
||||
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
|
||||
return HostnameStatusResponse(
|
||||
hostname=str(account["hostname"]),
|
||||
status="active" if account.get("accepted_at") else "pending",
|
||||
update_url=service.fritz_update_url(account_for_url),
|
||||
dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "",
|
||||
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,
|
||||
history=[_hostname_update_event(event) for event in history or []],
|
||||
)
|
||||
|
||||
|
||||
def _hostname_rotate_update_response(service: DdnsService, account: dict[str, str | int | None], dashboard_secret: str) -> HostnameRotateUpdateResponse:
|
||||
return HostnameRotateUpdateResponse(
|
||||
hostname=str(account["hostname"]),
|
||||
status="active" if account.get("accepted_at") else "pending",
|
||||
update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
|
||||
dashboard_url=service.dashboard_url({"management_slug": dashboard_secret}),
|
||||
)
|
||||
|
||||
|
||||
def _hostname_update_event(row: dict[str, str | int | None]) -> HostnameUpdateEvent:
|
||||
return HostnameUpdateEvent(
|
||||
hostname=str(row["hostname"]),
|
||||
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
|
||||
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
|
||||
status=str(row["status"]),
|
||||
detail=str(row["detail"]),
|
||||
created_at=str(row["created_at"]),
|
||||
count=int(row.get("count") or 1),
|
||||
)
|
||||
|
||||
|
||||
def _admin_hostname(account: dict[str, str | int | None]) -> AdminHostname:
|
||||
return AdminHostname(
|
||||
hostname=str(account["hostname"]),
|
||||
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
|
||||
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,
|
||||
created_at=str(account["created_at"]) if account.get("created_at") else None,
|
||||
accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None,
|
||||
disabled=bool(account.get("disabled")),
|
||||
)
|
||||
|
||||
|
||||
def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics:
|
||||
samples = metrics.get("update_latency_samples_total", 0)
|
||||
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
|
||||
return AdminMetrics(values=metrics, average_update_latency_ms=average_latency)
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .settings 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:
|
||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||
except Exception:
|
||||
return None, None
|
||||
if ":" not in raw:
|
||||
return None, None
|
||||
username, password = raw.split(":", 1)
|
||||
return username, password
|
||||
|
||||
|
||||
def hash_secret(token: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), salt.encode("ascii"), 200_000)
|
||||
return f"pbkdf2_sha256${salt}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_secret(token: str, stored: str) -> bool:
|
||||
try:
|
||||
algorithm, salt, expected = stored.split("$", 2)
|
||||
except ValueError:
|
||||
return False
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), salt.encode("ascii"), 200_000)
|
||||
return hmac.compare_digest(digest.hex(), expected)
|
||||
|
||||
|
||||
def hash_lookup_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def sign_dashboard_unlock_cookie(
|
||||
hostname: str,
|
||||
management_slug: str,
|
||||
password_hash: str,
|
||||
signing_secret: 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,
|
||||
signing_secret,
|
||||
timestamp,
|
||||
bounded_max_age,
|
||||
)
|
||||
return f"v3:{timestamp}:{bounded_max_age}:{signature}"
|
||||
|
||||
|
||||
def verify_dashboard_unlock_cookie(
|
||||
hostname: str,
|
||||
management_slug: str,
|
||||
password_hash: str | None,
|
||||
signing_secret: str,
|
||||
cookie_value: str | None,
|
||||
) -> bool:
|
||||
if not password_hash or not signing_secret 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 != "v3":
|
||||
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,
|
||||
signing_secret,
|
||||
timestamp,
|
||||
max_age_seconds,
|
||||
)
|
||||
return hmac.compare_digest(supplied, expected)
|
||||
|
||||
|
||||
def _dashboard_unlock_signature(
|
||||
hostname: str,
|
||||
management_slug: str,
|
||||
password_hash: str,
|
||||
signing_secret: str,
|
||||
timestamp: int,
|
||||
max_age_seconds: int,
|
||||
) -> str:
|
||||
payload = f"{hostname}\0{management_slug}\0{password_hash}\0{timestamp}\0{max_age_seconds}".encode()
|
||||
return hmac.new(signing_secret.encode(), payload, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str:
|
||||
return settings.dashboard_cookie_secret or settings.admin_password
|
||||
|
||||
|
||||
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")
|
||||
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:
|
||||
if not supplied or not hmac.compare_digest(supplied, admin_csrf_token(settings)):
|
||||
raise HTTPException(status_code=403, detail="invalid csrf token")
|
||||
|
||||
|
||||
def client_ip(request: Request, settings: DdnsSettings | None = None) -> str:
|
||||
peer = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded and settings and _trusted_proxy(peer, settings.trusted_proxy_ips):
|
||||
return forwarded.split(",", 1)[0].strip()
|
||||
return peer
|
||||
|
||||
|
||||
def is_rate_limited_path(path: str) -> bool:
|
||||
return path == "/" or path.startswith(("/m/", "/api/v1/hostnames", "/api/v1/router/"))
|
||||
|
||||
|
||||
def rate_limit_bucket(path: str) -> str:
|
||||
for prefix in ("/m/", "/api/v1/hostnames", "/api/v1/router/"):
|
||||
if path.startswith(prefix):
|
||||
return prefix.rstrip("/")
|
||||
return path
|
||||
|
||||
|
||||
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
|
||||
if not trusted:
|
||||
return False
|
||||
try:
|
||||
peer_ip = ip_address(peer)
|
||||
except ValueError:
|
||||
return False
|
||||
for candidate in trusted:
|
||||
try:
|
||||
if "/" in candidate and peer_ip in ip_network(candidate, strict=False):
|
||||
return True
|
||||
if "/" not in candidate and peer_ip == ip_address(candidate):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
@@ -0,0 +1,300 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from ..dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
|
||||
from ..event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED
|
||||
from ..models import UpdateResult
|
||||
from ..security import client_ip
|
||||
from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UpdateOutcome:
|
||||
result: UpdateResult
|
||||
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 = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
|
||||
|
||||
def create_managed_account(self) -> dict[str, str]:
|
||||
if not self.settings.hostname_suffix:
|
||||
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
|
||||
for _ in range(5):
|
||||
try:
|
||||
return self.store.create_account(self.random_managed_hostname(), accepted=False)
|
||||
except sqlite3.IntegrityError:
|
||||
continue
|
||||
raise HTTPException(status_code=500, detail="could not allocate hostname")
|
||||
|
||||
def delete_account(self, hostname: str, source: str) -> None:
|
||||
try:
|
||||
delete_dns_records(hostname, self.settings)
|
||||
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)
|
||||
|
||||
def publish_initial_dns(
|
||||
self,
|
||||
request: Request,
|
||||
hostname: str,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> None:
|
||||
if not dns_backend_configured(self.settings):
|
||||
return
|
||||
source_ip = client_ip(request, self.settings)
|
||||
source_fallback = source_ip if _is_global_ip(source_ip) else ""
|
||||
try:
|
||||
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
|
||||
source_fallback,
|
||||
myip,
|
||||
ipaddr,
|
||||
myipv6,
|
||||
ip6addr,
|
||||
)
|
||||
except BadIpError as exc:
|
||||
raise BadIpError("registration needs a public source IP or myip/myipv6 parameter") from exc
|
||||
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 DnsReconciliationError as exc:
|
||||
LOGGER.warning(
|
||||
"Initial DNS reconciliation 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_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
|
||||
raise
|
||||
except DnsProviderError as exc:
|
||||
LOGGER.warning(
|
||||
"Initial 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.commit_update_result(planned)
|
||||
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "initial dns registered", source_ip)
|
||||
|
||||
def apply_update(
|
||||
self,
|
||||
request: Request,
|
||||
hostname: str,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> UpdateOutcome:
|
||||
started_at = time.perf_counter()
|
||||
is_success = False
|
||||
is_dns_failure = False
|
||||
is_reconcile_failure = False
|
||||
is_bad_ip = False
|
||||
source_ip = client_ip(request, self.settings)
|
||||
try:
|
||||
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 DnsReconciliationError as exc:
|
||||
is_reconcile_failure = True
|
||||
LOGGER.warning(
|
||||
"DNS reconciliation 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_RECONCILE_FAILED,
|
||||
f"{type(exc).__name__}: {exc}",
|
||||
source_ip,
|
||||
)
|
||||
raise
|
||||
except DnsProviderError as exc:
|
||||
is_dns_failure = True
|
||||
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.commit_update_result(planned)
|
||||
is_success = True
|
||||
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "ok", source_ip)
|
||||
else:
|
||||
result = planned
|
||||
is_success = True
|
||||
self.store.log_update_rollup(hostname, result.ipv4, result.ipv6, UNCHANGED, "ok", source_ip)
|
||||
return UpdateOutcome(result=result, source_ip=source_ip)
|
||||
except BadIpError:
|
||||
is_bad_ip = True
|
||||
raise
|
||||
finally:
|
||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||
self.store.record_update_metrics(
|
||||
elapsed_ms,
|
||||
is_success=is_success,
|
||||
is_dns_failure=is_dns_failure,
|
||||
is_reconcile_failure=is_reconcile_failure,
|
||||
is_bad_ip=is_bad_ip,
|
||||
)
|
||||
|
||||
def fritz_update_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
slug = urllib.parse.quote(account["update_slug"])
|
||||
return f"{base}/api/v1/router/update/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
|
||||
|
||||
def dashboard_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
slug = urllib.parse.quote(account["management_slug"])
|
||||
return f"{base}/m/{slug}"
|
||||
|
||||
def registration_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
slug = urllib.parse.quote(account["management_slug"])
|
||||
return f"{base}/api/v1/router/register/{slug}"
|
||||
|
||||
def random_managed_hostname(self) -> str:
|
||||
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
||||
if not suffix:
|
||||
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
|
||||
return f"{secrets.token_hex(6)}.{suffix}"
|
||||
|
||||
def hostname_is_managed(self, hostname: str) -> bool:
|
||||
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
||||
if not suffix:
|
||||
return True
|
||||
normalized = hostname.strip().lower().rstrip(".")
|
||||
return normalized == suffix or normalized.endswith(f".{suffix}")
|
||||
|
||||
def _lock_for(self, hostname: str) -> threading.Lock:
|
||||
return self._locks[hash(hostname) % self._LOCK_STRIPES]
|
||||
|
||||
|
||||
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
try:
|
||||
ip = ipaddress.ip_address(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
if version and ip.version != version:
|
||||
return None
|
||||
return str(ip)
|
||||
|
||||
|
||||
def parse_update_ips(
|
||||
source_ip: str,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> tuple[str | None, str | None, bool, bool]:
|
||||
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:
|
||||
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 _is_global_ip(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
return ipaddress.ip_address(value.strip()).is_global
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
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(".")
|
||||
if expand_suffix and suffix and "." not in hostname:
|
||||
hostname = f"{hostname}.{suffix}"
|
||||
if not _valid_domain_name(hostname):
|
||||
return ""
|
||||
return hostname
|
||||
|
||||
|
||||
def _valid_domain_name(value: str) -> bool:
|
||||
label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
||||
return bool(re.fullmatch(rf"{label}(\.{label})+", value))
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class DdnsSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
database_path: Path = Field(default=Path("ddns.sqlite3"))
|
||||
admin_password: str = Field(default="")
|
||||
admin_path: str = Field(default="/admin")
|
||||
public_base_url: str = Field(default="http://localhost:8080")
|
||||
trusted_hosts: set[str] = Field(default_factory=set)
|
||||
hostname_suffix: str = Field(default="")
|
||||
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
||||
admin_rate_limit_per_minute: int = Field(default=20, ge=1, le=10_000)
|
||||
max_request_body_bytes: int = Field(default=16_384, ge=1024, le=1_048_576)
|
||||
trusted_proxy_ips: set[str] = Field(default_factory=set)
|
||||
rfc2136_server: str | None = None
|
||||
rfc2136_zone: str | None = None
|
||||
rfc2136_key_name: str | None = None
|
||||
rfc2136_key_secret: str | None = None
|
||||
dns_provider: str = Field(default="")
|
||||
dns_zones: set[str] = Field(default_factory=set)
|
||||
require_dns_provider: bool = Field(default=False)
|
||||
cloudflare_api_token: str = Field(default="")
|
||||
cloudflare_zone_id: str = Field(default="")
|
||||
cleanup_pending_account_hours: int = Field(default=24, ge=1, le=24 * 30)
|
||||
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)
|
||||
dashboard_cookie_secret: str = Field(default="")
|
||||
redacted_access_log: bool = Field(default=False)
|
||||
ttl: int = Field(default=60, ge=60, le=86400)
|
||||
|
||||
@field_validator("public_base_url")
|
||||
@classmethod
|
||||
def validate_public_base_url(cls, value: str) -> str:
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise ValueError("public_base_url must start with http:// or https://")
|
||||
return value.rstrip("/")
|
||||
|
||||
@field_validator("trusted_hosts")
|
||||
@classmethod
|
||||
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 = {
|
||||
"/admin.css",
|
||||
"/admin-tables.js",
|
||||
"/api",
|
||||
"/app.js",
|
||||
"/clipboard.js",
|
||||
"/docs",
|
||||
"/favicon.ico",
|
||||
"/healthz",
|
||||
"/install.sh",
|
||||
"/bootstrap-subset.css",
|
||||
"/logo.png",
|
||||
"/logo-nav.webp",
|
||||
"/logo.webp",
|
||||
"/m",
|
||||
"/openapi.json",
|
||||
"/privacy",
|
||||
"/redoc",
|
||||
"/robots.txt",
|
||||
"/sitemap.xml",
|
||||
"/site.webmanifest",
|
||||
"/social-preview.png",
|
||||
"/social-preview.webp",
|
||||
"/theme.js",
|
||||
"/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)
|
||||
public_host = urllib.parse.urlparse(self.public_base_url).hostname
|
||||
if public_host:
|
||||
hosts.add(public_host.lower())
|
||||
if not self.require_dns_provider:
|
||||
hosts.update({"testserver", "localhost", "127.0.0.1", "::1"})
|
||||
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:
|
||||
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
|
||||
if len(self.dashboard_cookie_secret or self.admin_password) < 32:
|
||||
raise RuntimeError(
|
||||
"DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1"
|
||||
)
|
||||
if not self.hostname_suffix:
|
||||
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
|
||||
if not self.effective_trusted_hosts:
|
||||
raise RuntimeError("At least one trusted host is required for production")
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> DdnsSettings:
|
||||
from .config import settings_from_env
|
||||
|
||||
return settings_from_env()
|
||||
@@ -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 .metrics import MetricsMixin
|
||||
from .rate_limit import RateLimitCleanupMixin
|
||||
from .records import RecordsEventsMixin
|
||||
from .scheduler import SchedulerMixin
|
||||
from .schema import connect_database, initialize_schema
|
||||
|
||||
|
||||
class DdnsStore(AccountsMixin, MetricsMixin, 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"]
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..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, *, accepted: bool = True) -> AccountRow:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
update_slug = secrets.token_urlsafe(32)
|
||||
management_slug = secrets.token_urlsafe(32)
|
||||
management_hash = hash_lookup_token(management_slug)
|
||||
accepted_at = now if accepted else None
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts(hostname, update_slug, management_hash, created_at, accepted_at, disabled)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(hostname, update_slug, management_hash, now, accepted_at),
|
||||
)
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"update_slug": update_slug,
|
||||
"management_slug": management_slug,
|
||||
"accepted_at": accepted_at,
|
||||
}
|
||||
|
||||
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT accounts.hostname, accounts.update_slug,
|
||||
accounts.created_at, accounts.accepted_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:
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
|
||||
accounts.created_at, accounts.accepted_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:
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT hostname, update_slug, accepted_at, 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:
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT accounts.hostname, accounts.update_slug, accounts.dashboard_password_hash,
|
||||
accounts.created_at, accounts.accepted_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 activate_account(self, hostname: str) -> AccountRow | None:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET accepted_at = COALESCE(accepted_at, ?) WHERE hostname = ?",
|
||||
(now, hostname),
|
||||
)
|
||||
return self.get_account_by_hostname(hostname)
|
||||
|
||||
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 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:
|
||||
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"])
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
class MetricsMixin:
|
||||
def increment_metric(self, name: str, amount: float = 1.0) -> None:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO service_metrics(name, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
value=value + excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(name, amount, now),
|
||||
)
|
||||
|
||||
def set_metric(self, name: str, value: float) -> None:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO service_metrics(name, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
value=excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(name, value, now),
|
||||
)
|
||||
|
||||
def get_service_metrics(self) -> dict[str, float]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT name, value FROM service_metrics").fetchall()
|
||||
return {str(row["name"]): float(row["value"]) for row in rows}
|
||||
|
||||
def record_update_metrics(
|
||||
self,
|
||||
latency_ms: float,
|
||||
*,
|
||||
is_success: bool = False,
|
||||
is_dns_failure: bool = False,
|
||||
is_reconcile_failure: bool = False,
|
||||
is_bad_ip: bool = False,
|
||||
) -> None:
|
||||
increments = {
|
||||
"update_requests_total": 1,
|
||||
"update_latency_ms_total": latency_ms,
|
||||
"update_latency_samples_total": 1,
|
||||
}
|
||||
if is_success:
|
||||
increments["update_success_total"] = 1
|
||||
if is_dns_failure:
|
||||
increments["dns_failures_total"] = 1
|
||||
if is_reconcile_failure:
|
||||
increments["dns_reconcile_failures_total"] = 1
|
||||
if is_bad_ip:
|
||||
increments["bad_ip_total"] = 1
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self.connect() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO service_metrics(name, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
value=value + excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
[(name, value, now) for name, value in increments.items()],
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO service_metrics(name, value, updated_at)
|
||||
VALUES ('last_update_latency_ms', ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
value=excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(latency_ms, now),
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT created_at, 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,
|
||||
unused_account_hours: int,
|
||||
event_history_hours: int = 24 * 180,
|
||||
pending_account_hours: int = 24,
|
||||
) -> dict[str, int]:
|
||||
self.flush_rate_limits()
|
||||
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
|
||||
pending_account_cutoff = (datetime.now(UTC) - timedelta(hours=pending_account_hours)).isoformat()
|
||||
event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
|
||||
with self.connect() as conn:
|
||||
account_cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM accounts
|
||||
WHERE (
|
||||
accepted_at IS NULL
|
||||
AND created_at < ?
|
||||
)
|
||||
OR (
|
||||
accepted_at IS NOT NULL
|
||||
AND created_at < ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
|
||||
)
|
||||
)
|
||||
""",
|
||||
(pending_account_cutoff, 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, unused_accounts_deleted)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(datetime.now(UTC).isoformat(), account_cursor.rowcount),
|
||||
)
|
||||
return {
|
||||
"unused_accounts": account_cursor.rowcount,
|
||||
"event_rollups": rollup_cursor.rowcount,
|
||||
"update_events": event_cursor.rowcount,
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ..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:
|
||||
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:
|
||||
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:
|
||||
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]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, count
|
||||
FROM (
|
||||
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
|
||||
FROM update_events
|
||||
UNION ALL
|
||||
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
|
||||
FROM update_event_rollups
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def list_update_events_before(self, limit: int = 100, before: str | None = None) -> list[UpdateEventRow]:
|
||||
where_clause = "WHERE created_at < ?" if before else ""
|
||||
params: tuple[object, ...] = (before, limit) if before else (limit,)
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, count
|
||||
FROM (
|
||||
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
|
||||
FROM update_events
|
||||
UNION ALL
|
||||
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
|
||||
FROM update_event_rollups
|
||||
)
|
||||
{where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
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:
|
||||
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]
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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:
|
||||
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]
|
||||
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from ..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 = 3
|
||||
|
||||
|
||||
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)
|
||||
conn.row_factory = sqlite3.Row
|
||||
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)
|
||||
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
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,
|
||||
unused_accounts_deleted INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
_migrate_cleanup_runs_schema(conn)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
|
||||
conn.execute(
|
||||
"""
|
||||
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 service_metrics (
|
||||
name TEXT PRIMARY KEY,
|
||||
value REAL NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
update_slug TEXT NOT NULL UNIQUE,
|
||||
management_hash TEXT UNIQUE,
|
||||
dashboard_password_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
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")
|
||||
_ensure_column(conn, "accounts", "accepted_at", "TEXT")
|
||||
if schema_version < 2:
|
||||
conn.execute("UPDATE accounts SET accepted_at = created_at WHERE accepted_at IS NULL")
|
||||
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]),
|
||||
)
|
||||
_migrate_accounts_schema(conn)
|
||||
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_accepted_at ON accounts(accepted_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at)")
|
||||
if schema_version < 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}")
|
||||
|
||||
|
||||
def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(accounts)")}
|
||||
if "username" not in columns and "token_hash" not in columns:
|
||||
return
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_update_slug")
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_management_hash")
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_accepted_at")
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_created_at")
|
||||
conn.execute("DROP INDEX IF EXISTS idx_accounts_hostname_username")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE accounts_new (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
update_slug TEXT NOT NULL UNIQUE,
|
||||
management_hash TEXT UNIQUE,
|
||||
dashboard_password_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
disabled INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled)
|
||||
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
|
||||
FROM accounts
|
||||
"""
|
||||
)
|
||||
conn.execute("DROP TABLE accounts")
|
||||
conn.execute("ALTER TABLE accounts_new RENAME TO accounts")
|
||||
|
||||
|
||||
def _migrate_cleanup_runs_schema(conn: sqlite3.Connection) -> None:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
|
||||
if columns <= {"id", "created_at", "unused_accounts_deleted"}:
|
||||
return
|
||||
conn.execute("DROP INDEX IF EXISTS idx_cleanup_runs_created_at")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE cleanup_runs_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
unused_accounts_deleted INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cleanup_runs_new(id, created_at, unused_accounts_deleted)
|
||||
SELECT id, created_at, unused_accounts_deleted
|
||||
FROM cleanup_runs
|
||||
"""
|
||||
)
|
||||
conn.execute("DROP TABLE cleanup_runs")
|
||||
conn.execute("ALTER TABLE cleanup_runs_new RENAME TO cleanup_runs")
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class AccountRow(TypedDict, total=False):
|
||||
hostname: str
|
||||
update_slug: str
|
||||
management_slug: str
|
||||
dashboard_password_hash: str | None
|
||||
created_at: str
|
||||
accepted_at: str | None
|
||||
disabled: int
|
||||
ipv4: str | None
|
||||
ipv6: str | None
|
||||
updated_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
|
||||
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
|
||||
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address
|
||||
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .icons import _svg_icon
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"admin_hostname_deleted": "hostname deleted by admin",
|
||||
"admin_management_url_rotated": "dashboard link rotated by admin",
|
||||
"admin_update_url_rotated": "update URL rotated by admin",
|
||||
"dashboard_hostname_activated": "hostname registered",
|
||||
"dashboard_hostname_deleted": "hostname deleted",
|
||||
"dashboard_password_changed": "dashboard password changed",
|
||||
"dashboard_password_enabled": "dashboard password enabled",
|
||||
"dashboard_password_removed": "dashboard password removed",
|
||||
"dashboard_update_url_rotated": "update URL rotated",
|
||||
"dns_delete_failed": "DNS delete failed",
|
||||
"dns_failed": "DNS update failed",
|
||||
"dns_reconcile_failed": "DNS reconciliation failed",
|
||||
"unchanged": "unchanged",
|
||||
"updated": "updated",
|
||||
}
|
||||
|
||||
|
||||
def _event_label(status: object) -> str:
|
||||
value = str(status or "-")
|
||||
return _EVENT_LABELS.get(value, value.replace("_", " "))
|
||||
|
||||
|
||||
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 copy-button" data-copy="{safe_value}" aria-label="Copy {safe_label}">
|
||||
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = attr(value)
|
||||
safe_style = attr(style)
|
||||
return trusted(
|
||||
f'<button type="button" class="btn btn-{safe_style} rounded-pill copy-button" data-copy="{safe_value}" '
|
||||
f'aria-label="Copy {safe_label}">{_svg_icon("clipboard")}<span data-copy-label>{safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
|
||||
|
||||
def _copy_icon_button(label: str, value: str) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
safe_value = attr(value)
|
||||
return trusted(
|
||||
f'<button type="button" class="copy-icon-button" data-copy="{safe_value}" aria-label="Copy {safe_label}" title="Copy">'
|
||||
f'{_svg_icon("clipboard")}<span class="visually-hidden" data-copy-label>Copy {safe_label}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
|
||||
|
||||
def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
|
||||
safe_label = escape(label)
|
||||
buttons = []
|
||||
for item_label, item_value in items:
|
||||
buttons.append(
|
||||
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
|
||||
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
|
||||
)
|
||||
return trusted(
|
||||
f"""
|
||||
<details class="copy-menu">
|
||||
<summary aria-label="{safe_label}" title="{safe_label}">
|
||||
{_svg_icon("more", "more-icon")}
|
||||
</summary>
|
||||
<div class="copy-menu-popover" role="menu">
|
||||
{''.join(buttons)}
|
||||
</div>
|
||||
</details>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None) -> SafeHtml:
|
||||
safe_class = f" {attr(class_name)}" if class_name else ""
|
||||
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
|
||||
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="terminal-card code-window{safe_class}">
|
||||
{_copy_icon_button(copy_label, copy_value)}
|
||||
{code_lines}
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _code_copy_value(commands: str) -> str:
|
||||
lines = commands.splitlines()
|
||||
prompt_lines = [line[2:] for line in lines if line.startswith("$ ")]
|
||||
if prompt_lines:
|
||||
return "\n".join(prompt_lines)
|
||||
return "\n".join(line[2:] if line.startswith("$ ") else line for line in lines)
|
||||
|
||||
|
||||
def _example_update_ips(request_ip: str | None = None) -> tuple[str, str]:
|
||||
ipv4 = "203.0.113.10"
|
||||
ipv6 = "2001:db8::10"
|
||||
if not request_ip:
|
||||
return ipv4, ipv6
|
||||
try:
|
||||
parsed = ip_address(request_ip.strip())
|
||||
except ValueError:
|
||||
return ipv4, ipv6
|
||||
if not parsed.is_global:
|
||||
return ipv4, ipv6
|
||||
if parsed.version == 4:
|
||||
return str(parsed), ipv6
|
||||
return ipv4, str(parsed)
|
||||
|
||||
|
||||
def _manual_update_commands(update_url: str, request_ip: str | None = None) -> str:
|
||||
update_base = update_url.split("?", 1)[0]
|
||||
ipv4, ipv6 = _example_update_ips(request_ip)
|
||||
return "\n".join(
|
||||
[
|
||||
f"curl -sS '{update_base}?myip={ipv4}'",
|
||||
f"curl -sS '{update_base}?myipv6={ipv6}'",
|
||||
f"curl -sS '{update_base}' # use request IP",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _manual_update_panel(commands: str) -> SafeHtml:
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div>
|
||||
<h3>Manual updates</h3>
|
||||
<p class="section-copy">FRITZ!Box fills in the IP placeholders automatically. Scripts can use curl. If no IP is sent, KarlDNS uses the request IP.</p>
|
||||
</div>
|
||||
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _fritzbox_fields(
|
||||
update_url: str,
|
||||
hostname: str,
|
||||
management_url: str | None = None,
|
||||
) -> str:
|
||||
lines = [
|
||||
f"Update-URL: {update_url}",
|
||||
f"Domainnamen: {hostname}",
|
||||
f"CNAME target: {hostname}",
|
||||
]
|
||||
if management_url:
|
||||
lines.append(f"Private dashboard: {management_url}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
|
||||
secret_items = "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
|
||||
return trusted(
|
||||
f"""
|
||||
<div class="alert alert-info my-3 py-2 small" role="note">
|
||||
<strong>Public:</strong> Hostname and CNAME target can be shared.
|
||||
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
|
||||
Secrets are hidden 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 private URLs now. Newly created or rotated secrets are shown only once.
|
||||
</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>
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from ..settings 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()
|
||||
@@ -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))
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from .html_utils import SafeHtml, attr, trusted
|
||||
|
||||
_ICON_DIR = Path(__file__).parents[1] / "assets" / "lucide"
|
||||
_ICON_FILES = {
|
||||
"blog": "newspaper",
|
||||
"business": "briefcase",
|
||||
"docs": "book-open",
|
||||
"github": "code",
|
||||
"more": "ellipsis-vertical",
|
||||
"privacy": "shield-check",
|
||||
}
|
||||
|
||||
|
||||
def _svg_icon(name: str, css_class: str = "copy-icon") -> SafeHtml:
|
||||
path = _lucide_body(name)
|
||||
safe_class = attr(f"lucide-icon {css_class}")
|
||||
return trusted(
|
||||
f'<svg class="{safe_class}" aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24" '
|
||||
f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{path}</svg>'
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def _lucide_body(name: str) -> str:
|
||||
file_stem = _ICON_FILES.get(name, name)
|
||||
raw = (_ICON_DIR / f"{file_stem}.svg").read_text(encoding="utf-8")
|
||||
match = re.search(r"<svg\b[^>]*>(.*?)</svg>", raw, flags=re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid Lucide SVG: {file_stem}")
|
||||
return re.sub(r"\s+", " ", match.group(1).strip())
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .icons import _svg_icon
|
||||
|
||||
|
||||
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">API</a>')
|
||||
theme_toggle = f"""
|
||||
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
|
||||
{_svg_icon("moon", "theme-icon theme-icon-moon")}
|
||||
{_svg_icon("sun", "theme-icon theme-icon-sun")}
|
||||
</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-nav.webp?v=3" alt="" width="28" height="28" decoding="async">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 _site_footer() -> SafeHtml:
|
||||
return trusted(
|
||||
f"""
|
||||
<footer class="site-footer border-top">
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
|
||||
<nav class="footer-links" aria-label="Footer links">
|
||||
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
|
||||
{_svg_icon("github", "footer-icon")}
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a class="footer-link" href="https://karl.fail" rel="noopener">
|
||||
{_svg_icon("blog", "footer-icon")}
|
||||
<span>karl.fail</span>
|
||||
</a>
|
||||
<a class="footer-link" href="https://karlcom.de" rel="noopener">
|
||||
{_svg_icon("business", "footer-icon")}
|
||||
<span>karlcom.de</span>
|
||||
</a>
|
||||
<a class="footer-link" href="/docs">
|
||||
{_svg_icon("docs", "footer-icon")}
|
||||
<span>API</span>
|
||||
</a>
|
||||
<a class="footer-link" href="/privacy">
|
||||
{_svg_icon("privacy", "footer-icon")}
|
||||
<span>Legal & privacy</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _page(
|
||||
title: str,
|
||||
body: SafeHtml,
|
||||
extra_styles: SafeHtml | None = None,
|
||||
extra_scripts: SafeHtml | None = None,
|
||||
description: str = "KarlDNS is a small self-hosted Dynamic DNS service for FRITZ!Box routers, Linux cron jobs, and home networks.",
|
||||
preload_logo: bool = False,
|
||||
canonical_url: str | None = None,
|
||||
image_url: str | None = None,
|
||||
image_alt: str | None = None,
|
||||
structured_data: dict[str, object] | None = None,
|
||||
) -> str:
|
||||
if not isinstance(body, SafeHtml):
|
||||
raise TypeError("body must be a SafeHtml fragment built by a template helper")
|
||||
extra_styles_html = extra_styles or trusted("")
|
||||
extra_scripts_html = extra_scripts or trusted("")
|
||||
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
|
||||
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
|
||||
social_url = canonical_url or ""
|
||||
social_image = image_url or ""
|
||||
social_html = trusted("")
|
||||
if social_url:
|
||||
social_card = "summary_large_image" if social_image else "summary"
|
||||
social_image_alt = image_alt or title
|
||||
social_image_html = f"""
|
||||
<meta property="og:image" content="{attr(social_image)}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:alt" content="{attr(social_image_alt)}">
|
||||
<meta name="twitter:image" content="{attr(social_image)}">
|
||||
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
|
||||
""" if social_image else ""
|
||||
social_html = trusted(f"""
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{attr(title)}">
|
||||
<meta property="og:description" content="{attr(description)}">
|
||||
<meta property="og:url" content="{attr(social_url)}">
|
||||
{social_image_html}
|
||||
<meta name="twitter:card" content="{attr(social_card)}">
|
||||
<meta name="twitter:title" content="{attr(title)}">
|
||||
<meta name="twitter:description" content="{attr(description)}">
|
||||
""")
|
||||
structured_data_html = trusted("")
|
||||
if structured_data:
|
||||
structured_data_html = trusted(
|
||||
'<script type="application/ld+json">'
|
||||
+ escape(json.dumps(structured_data, ensure_ascii=False, separators=(",", ":")))
|
||||
+ "</script>"
|
||||
)
|
||||
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">
|
||||
<meta name="description" content="{attr(description)}">
|
||||
{canonical_html}
|
||||
{social_html}
|
||||
<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">
|
||||
{preload_logo_html}
|
||||
<script src="/theme.js?v=2"></script>
|
||||
<script src="/clipboard.js?v=3" defer></script>
|
||||
{extra_scripts_html}
|
||||
<link href="/bootstrap-subset.css?v=3" rel="stylesheet">
|
||||
<link href="/app.css?v=12" rel="stylesheet">
|
||||
{extra_styles_html}
|
||||
{structured_data_html}
|
||||
</head>
|
||||
<body>{body}{_site_footer()}</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
THEME_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) => {
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
button.dataset.themeNext = nextTheme;
|
||||
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} 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);
|
||||
});
|
||||
})();
|
||||
""".strip()
|
||||
|
||||
|
||||
CLIPBOARD_JS = """
|
||||
document.addEventListener("click", async (event) => {
|
||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||
if (!menu.contains(event.target)) menu.removeAttribute("open");
|
||||
});
|
||||
|
||||
const toggle = event.target.closest("[data-secret-toggle]");
|
||||
if (toggle) {
|
||||
const group = toggle.closest(".copy-field");
|
||||
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 label = button.querySelector("[data-copy-label]");
|
||||
const oldLabel = label ? label.textContent : "";
|
||||
button.classList.add("is-copied");
|
||||
if (label) label.textContent = "Copied";
|
||||
const menu = button.closest("details.copy-menu");
|
||||
if (menu) {
|
||||
setTimeout(() => {
|
||||
menu.removeAttribute("open");
|
||||
}, 250);
|
||||
}
|
||||
setTimeout(() => {
|
||||
button.classList.remove("is-copied");
|
||||
if (label) label.textContent = oldLabel;
|
||||
}, 1200);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||
menu.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
""".strip()
|
||||
|
||||
|
||||
ADMIN_TABLES_JS = """
|
||||
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()
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
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 ..security import client_ip, is_rate_limited_path, rate_limit_bucket
|
||||
from ..settings import DdnsSettings
|
||||
from ..store import DdnsStore
|
||||
|
||||
ACCESS_LOGGER = logging.getLogger("karldns.access")
|
||||
|
||||
|
||||
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):
|
||||
started_at = time.perf_counter()
|
||||
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:
|
||||
await run_in_threadpool(store.increment_metric, "rate_limit_hits_total")
|
||||
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"
|
||||
if is_noindex_response(request):
|
||||
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
||||
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"
|
||||
if settings.redacted_access_log:
|
||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||
ACCESS_LOGGER.info(
|
||||
"%s %s status=%s duration_ms=%.2f",
|
||||
request.method,
|
||||
redacted_access_path(request.url.path),
|
||||
response.status_code,
|
||||
elapsed_ms,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
|
||||
admin_path = settings.admin_path.rstrip("/")
|
||||
return path == admin_path or path.startswith(f"{admin_path}/") or path.startswith("/api/v1/admin/")
|
||||
|
||||
|
||||
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/"):
|
||||
return True
|
||||
if path.startswith(("/api/v1/hostnames", "/api/v1/router/")):
|
||||
return True
|
||||
if path.startswith("/api/v1/admin/"):
|
||||
return True
|
||||
return request.method == "POST" and path == "/"
|
||||
|
||||
|
||||
def is_noindex_response(request: Request) -> bool:
|
||||
path = request.url.path
|
||||
if request.method != "GET":
|
||||
return True
|
||||
if path in {"/openapi.json", "/healthz"}:
|
||||
return True
|
||||
if path.startswith(("/api/", "/m/")):
|
||||
return True
|
||||
return is_admin_path(path, request.app.state.settings)
|
||||
|
||||
|
||||
def content_security_policy(path: str) -> str:
|
||||
script_src = "script-src 'self' 'unsafe-inline'; " if path in {"/", "/privacy"} else "script-src 'self'; "
|
||||
return (
|
||||
"default-src 'self'; "
|
||||
f"{script_src}"
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
|
||||
|
||||
def redacted_access_path(path: str) -> str:
|
||||
secret_prefixes = {
|
||||
"/m": "/m/<secret>",
|
||||
"/api/v1/hostnames": "/api/v1/hostnames/<secret>",
|
||||
"/api/v1/router/register": "/api/v1/router/register/<secret>",
|
||||
"/api/v1/router/update": "/api/v1/router/update/<secret>",
|
||||
}
|
||||
for prefix, replacement in secret_prefixes.items():
|
||||
if path == prefix or path.startswith(f"{prefix}/"):
|
||||
return replacement
|
||||
return path
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ..security import admin_csrf_token
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .templates_admin_tables import (
|
||||
_account_row,
|
||||
_admin_table_pagination,
|
||||
_admin_table_toolbar,
|
||||
_event_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 these values now. Secret values are only shown once.</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]],
|
||||
cleanup_runs: list[dict[str, str | int]],
|
||||
scheduled_tasks: list[dict[str, str | int | None]],
|
||||
update_events: list[dict[str, str | int | None]],
|
||||
service_metrics: dict[str, float],
|
||||
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)
|
||||
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>
|
||||
"""
|
||||
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)))
|
||||
event_count = escape(table_counts.get("update_events", len(update_events)))
|
||||
metrics_html = _metrics_panel(service_metrics)
|
||||
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
|
||||
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
|
||||
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
|
||||
return _page(
|
||||
"KarlDNS Admin",
|
||||
trusted(f"""
|
||||
<main id="main">
|
||||
{_top_nav("Operator")}
|
||||
<section class="section admin-heading">
|
||||
<div class="container">
|
||||
<p class="eyebrow">KarlDNS</p>
|
||||
<h1>Operator console</h1>
|
||||
<p class="lead">Review hostnames, updates, cleanup, and secret changes.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice_html or ""}
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="d-flex align-items-md-center justify-content-between gap-4 flex-column flex-md-row mb-4">
|
||||
<div>
|
||||
<p class="eyebrow">Service health</p>
|
||||
<h2>Service status</h2>
|
||||
</div>
|
||||
</div>
|
||||
{cleanup_panel}
|
||||
<div class="mt-4">
|
||||
{metrics_html}
|
||||
</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>Hostnames</h2>
|
||||
<p class="section-copy mb-0">Showing {escape(len(accounts))} of {accounts_count}.</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 scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Updated</th><th scope="col">Links</th><th scope="col">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</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">Updates</p>
|
||||
<h2>Update events</h2>
|
||||
<p class="section-copy mb-0">Showing {escape(len(update_events))} of {event_count}. Repeated checks with no IP change are grouped.</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 scope="col">Time</th><th scope="col">Hostname</th><th scope="col">Status</th><th scope="col">IPv4</th><th scope="col">IPv6</th><th scope="col">Count</th><th scope="col">Detail</th></tr></thead>
|
||||
<tbody>{event_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{_admin_table_pagination()}
|
||||
{event_pager}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
|
||||
extra_scripts=trusted('<script src="/admin-tables.js?v=2" defer></script>'),
|
||||
)
|
||||
|
||||
|
||||
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</a>
|
||||
<a class="btn btn-sm btn-light rounded-pill{next_disabled}" href="{next_href}" aria-disabled="{str(next_offset >= total).lower()}">Next</a>
|
||||
</nav>
|
||||
""")
|
||||
|
||||
|
||||
def _cleanup_panel(
|
||||
settings: DdnsSettings,
|
||||
cleanup_runs: list[dict[str, str | int]],
|
||||
scheduled_tasks: list[dict[str, str | int | None]],
|
||||
safe_admin_path: str,
|
||||
csrf: str,
|
||||
) -> SafeHtml:
|
||||
task = next((item for item in scheduled_tasks if item.get("name") == "cleanup"), scheduled_tasks[0] if scheduled_tasks else {})
|
||||
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
|
||||
next_run = task.get("next_run_at")
|
||||
status = task.get("last_status") or "pending"
|
||||
last_error = task.get("last_error") or ""
|
||||
interval = task.get("interval_seconds") or settings.cleanup_interval_seconds
|
||||
last_cleanup = cleanup_runs[0] if cleanup_runs else {}
|
||||
accounts_removed = last_cleanup.get("unused_accounts_deleted", 0)
|
||||
error_html = (
|
||||
trusted(f'<p class="small text-danger mb-0">Last error: {escape(last_error)}</p>')
|
||||
if last_error
|
||||
else trusted("")
|
||||
)
|
||||
cards = [
|
||||
("Interval", f"*/{interval}s"),
|
||||
("Last run", str(last_run or "Never")),
|
||||
("Next run", str(next_run or "Pending")),
|
||||
("Status", str(status)),
|
||||
]
|
||||
card_html = "\n".join(
|
||||
f"""
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="metric-card h-100">
|
||||
<span>{escape(label)}</span>
|
||||
<strong>{escape(value)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
for label, value in cards
|
||||
)
|
||||
return trusted(f"""
|
||||
<div class="card flat-card">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 flex-column flex-lg-row">
|
||||
<div>
|
||||
<h3 class="h5 mb-2">Cleanup</h3>
|
||||
<p class="section-copy mb-1">Last cleanup removed {escape(accounts_removed)} unused hostnames.</p>
|
||||
{error_html}
|
||||
</div>
|
||||
<form method="post" action="{safe_admin_path}/cleanup" aria-label="Run cleanup now">
|
||||
<input type="hidden" name="csrf" value="{csrf}">
|
||||
<button type="submit" class="btn btn-primary rounded-pill">Run now</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="row g-3 mt-3">
|
||||
{card_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
|
||||
samples = metrics.get("update_latency_samples_total", 0)
|
||||
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
|
||||
items = [
|
||||
("Updates", _metric_int(metrics.get("update_requests_total", 0))),
|
||||
("Updated", _metric_int(metrics.get("update_success_total", 0))),
|
||||
("DNS failures", _metric_int(metrics.get("dns_failures_total", 0))),
|
||||
("DNS check failures", _metric_int(metrics.get("dns_reconcile_failures_total", 0))),
|
||||
("Blocked requests", _metric_int(metrics.get("rate_limit_hits_total", 0))),
|
||||
("Average update time", f"{average_latency:.1f} ms"),
|
||||
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
|
||||
("Cleanup ok/errors", f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}"),
|
||||
]
|
||||
cards = "\n".join(
|
||||
f"""
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="metric-card h-100">
|
||||
<span>{escape(label)}</span>
|
||||
<strong>{escape(value)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
for label, value in items
|
||||
)
|
||||
return trusted(f"""
|
||||
<div class="mt-4">
|
||||
<p class="eyebrow">Metrics</p>
|
||||
<div class="row g-3 mt-1">{cards}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
def _metric_int(value: float) -> str:
|
||||
return str(int(value))
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .components import _event_label
|
||||
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"])
|
||||
status = "Active" if account.get("accepted_at") else "Pending"
|
||||
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>{escape(status)}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
|
||||
<td>
|
||||
<form method="post" action="{safe_admin_path}/hostnames/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="Create a new update URL" aria-label="New update URL for {hostname}">New update URL</button>
|
||||
<button name="action" value="management" class="btn btn-sm btn-light px-3" title="Create a new dashboard link" aria-label="New dashboard link for {hostname}">New dashboard link</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{safe_admin_path}/hostnames/delete">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname}">
|
||||
<button class="btn btn-sm btn-outline-danger px-2" title="Delete hostname" aria-label="Delete {hostname}">Delete</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 _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_label(event.get("status")))
|
||||
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>"
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .components import (
|
||||
_copy_menu,
|
||||
_copy_row,
|
||||
_event_label,
|
||||
_fritzbox_fields,
|
||||
_manual_update_commands,
|
||||
_manual_update_panel,
|
||||
_secret_notice,
|
||||
)
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .templates_public_sections import _created_hostname_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,
|
||||
created_account: dict[str, str] | None = None,
|
||||
request_ip: str | None = None,
|
||||
) -> str:
|
||||
account_for_url = {
|
||||
"hostname": str(account["hostname"]),
|
||||
"update_slug": str(account["update_slug"]),
|
||||
"management_slug": management_slug,
|
||||
}
|
||||
update_url = service.fritz_update_url(account_for_url)
|
||||
hostname = escape(account["hostname"])
|
||||
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)
|
||||
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
|
||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
||||
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_links = _created_hostname_panel(service, created_account, request_ip)
|
||||
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">Dashboard</p>
|
||||
<h1>{hostname}</h1>
|
||||
<p class="lead">This private link can manage this hostname. Keep it safe.</p>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
|
||||
</div>
|
||||
</section>
|
||||
{message_html}
|
||||
{rotated_links}
|
||||
<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 this Update-URL in your router or script. Optional: point another DNS name at this hostname with a CNAME.</p>
|
||||
{_secret_notice()}
|
||||
<div class="copy-actions mt-3">
|
||||
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("CNAME", cname_record)])}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 d-grid gap-2">
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
</div>
|
||||
</div>
|
||||
{_manual_update_panel(manual_commands)}
|
||||
<div class="card card-body gap-3 mt-4">
|
||||
<div>
|
||||
<p class="eyebrow">Actions</p>
|
||||
<h3>Replace router update URL</h3>
|
||||
<p class="section-copy">Create a new update URL. Update your router after changing it.</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">New update URL</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{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 updates 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">Delete</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>
|
||||
"""),
|
||||
extra_styles=trusted('<link href="/admin.css?v=2" rel="stylesheet">'),
|
||||
)
|
||||
|
||||
def _render_pending_management_page(
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: dict[str, str | int | None],
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
) -> str:
|
||||
hostname = escape(account["hostname"])
|
||||
message_html = _management_message(message)
|
||||
cleanup_hours = settings.cleanup_pending_account_hours
|
||||
cleanup_text = f"{cleanup_hours} hours" if cleanup_hours != 24 else "24 hours"
|
||||
return _page(
|
||||
"Register hostname",
|
||||
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">Pending setup</p>
|
||||
<h1>{hostname}</h1>
|
||||
<p class="lead">Register this hostname after you have copied the private values. Unregistered setups are cleaned up after {escape(cleanup_text)}.</p>
|
||||
<form method="post" action="/m/{attr(management_slug)}/activate">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{message_html}
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow text-center">
|
||||
<p class="section-copy mx-auto">Lost the first page? Create a new hostname. Private URLs are shown only once.</p>
|
||||
</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 password" if password_enabled else "Set 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 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 password</button>
|
||||
</form>
|
||||
"""
|
||||
current_password_field = ""
|
||||
if password_enabled:
|
||||
current_password_field = """
|
||||
<div>
|
||||
<label class="form-label" for="dashboard-current-password">Current 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 password protects only this dashboard. Router updates still work.</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 for 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">Remember this browser for 30 days</span>
|
||||
</label>
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Unlock</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
)
|
||||
|
||||
|
||||
def _user_event_row(event: dict[str, str | int | None]) -> str:
|
||||
created = escape(event.get("created_at") or "-")
|
||||
status = escape(_event_label(event.get("status")))
|
||||
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>"
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.ddns import DdnsService
|
||||
from ..settings import DdnsSettings
|
||||
from .components import _code_window, _example_update_ips, _message_band
|
||||
from .html_utils import escape, trusted
|
||||
from .layout import _page, _top_nav
|
||||
from .templates_public_sections import _created_hostname_panel, _linux_installer_section
|
||||
|
||||
|
||||
def _render_public_home(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
created_account: dict[str, str] | None,
|
||||
message: str | None,
|
||||
request_ip: str | None = None,
|
||||
) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
hostname_suffix = settings.hostname_suffix or "your KarlDNS suffix"
|
||||
suffix = escape(hostname_suffix)
|
||||
demo_update_url = escape(f"{base_url}/api/v1/router/update/<secret>?myip=<ipaddr>")
|
||||
example_ipv4, _ = _example_update_ips(request_ip)
|
||||
create_demo_command = f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'"
|
||||
create_demo = f"{create_demo_command}\ngood {example_ipv4}"
|
||||
manual_demo = "\n".join(
|
||||
[
|
||||
"# explicit IPv4",
|
||||
f"curl -sS '{base_url}/api/v1/router/update/<secret>?myip={example_ipv4}'",
|
||||
"# source IP fallback",
|
||||
f"curl -sS '{base_url}/api/v1/router/update/<secret>'",
|
||||
]
|
||||
)
|
||||
created_html = _created_hostname_panel(service, created_account, request_ip)
|
||||
message_html = _message_band(message) if message else ""
|
||||
description = "KarlDNS is a free self-hosted Dynamic DNS service for FRITZ!Box routers, generated KarlDNS hostnames, Linux cron jobs, and home networks."
|
||||
return _page(
|
||||
"KarlDNS",
|
||||
trusted(f"""
|
||||
<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.webp?v=3" alt="" width="80" height="80" decoding="async" fetchpriority="high">
|
||||
<h1>Dynamic DNS for home routers.</h1>
|
||||
<div class="install-pill mx-auto">
|
||||
<code>{demo_update_url}</code>
|
||||
</div>
|
||||
<p class="lead">KarlDNS gives you a generated DynDNS hostname under {suffix}. Add the private update URL to your router, or use it from a script.</p>
|
||||
<div class="d-flex gap-2 flex-wrap justify-content-center mt-2">
|
||||
<form method="post" action="/" class="hero-action-form" aria-label="Create a generated DynDNS hostname">
|
||||
<button type="submit" class="btn btn-primary rounded-pill">Create hostname</button>
|
||||
</form>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="#manual-updates">Manual updates</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{message_html}
|
||||
{created_html}
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow text-center">
|
||||
<p class="eyebrow">How it works</p>
|
||||
<h2>No account needed.</h2>
|
||||
<p class="section-copy mx-auto">Create a hostname, save the private values, register it, done.</p>
|
||||
<ol class="flow-steps" aria-label="KarlDNS setup flow">
|
||||
<li><strong>Create</strong><span>Random hostname and private links.</span></li>
|
||||
<li><strong>Save</strong><span>Copy the secrets while they are shown.</span></li>
|
||||
<li><strong>Register</strong><span>Activate the hostname when you are ready.</span></li>
|
||||
<li><strong>Update</strong><span>Router, cron, or CNAME can use it.</span></li>
|
||||
</ol>
|
||||
{_code_window(create_demo, copy_label="update example", class_name="mt-4", copy_value=create_demo_command)}
|
||||
</div>
|
||||
</section>
|
||||
<section class="section" id="manual-updates">
|
||||
<div class="container narrow text-center">
|
||||
<h2>Update from a router or script.</h2>
|
||||
<p class="lead mx-auto mt-3">Works with FRITZ!Box, curl, cron, Home Assistant, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, and custom tools.</p>
|
||||
{_code_window(manual_demo, copy_label="manual update examples", class_name="text-start mt-4")}
|
||||
</div>
|
||||
</section>
|
||||
{_linux_installer_section(base_url)}
|
||||
</main>
|
||||
"""),
|
||||
description=description,
|
||||
preload_logo=True,
|
||||
canonical_url=f"{base_url}/",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS landing page with private Dynamic DNS update URL",
|
||||
structured_data={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "KarlDNS",
|
||||
"applicationCategory": "NetworkingApplication",
|
||||
"operatingSystem": "Linux",
|
||||
"url": f"{base_url}/",
|
||||
"description": description,
|
||||
"image": f"{base_url}/social-preview.png",
|
||||
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
|
||||
"featureList": [
|
||||
"Dynamic DNS for FRITZ!Box routers",
|
||||
"Accountless private update URLs",
|
||||
"CNAME target for external DNS names",
|
||||
"Linux cron updater",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def _render_privacy_page(settings: DdnsSettings) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
|
||||
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>Privacy basics.</h1>
|
||||
<p class="lead">KarlDNS stores only the data needed to keep your hostname updated.</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 hostnames, protected dashboard tokens, private update links, recent IP history, timestamps, and service events.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Secrets</h2>
|
||||
<p class="section-copy">Dashboard links, registration URLs, and update URLs are secrets. Anyone with them may update or manage the hostname. Dashboard lookup values are not stored as readable text.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Logs and retention</h2>
|
||||
<p class="section-copy">KarlDNS keeps recent update events for troubleshooting. Cleanup removes unused hostnames. Server logs may also include IP addresses and request details.</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>
|
||||
"""),
|
||||
description=description,
|
||||
canonical_url=f"{base_url}/privacy",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS legal and privacy information",
|
||||
structured_data={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "KarlDNS Legal and Privacy",
|
||||
"url": f"{base_url}/privacy",
|
||||
"description": description,
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KarlDNS",
|
||||
"url": f"{base_url}/",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None) -> str:
|
||||
base_url = settings.public_base_url.rstrip("/")
|
||||
example_ipv4, _ = _example_update_ips(request_ip)
|
||||
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
|
||||
register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/<dashboard-secret>?myip={example_ipv4}'"
|
||||
update_example = f"curl -sS '{base_url}/api/v1/router/update/<update-secret>?myip={example_ipv4}'"
|
||||
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<dashboard-secret>?history_limit=10'"
|
||||
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<dashboard-secret>/rotate-update-url"
|
||||
schema_example = f"curl -sS {base_url}/openapi.json"
|
||||
admin_dashboard_example = f"curl -sS -u admin:<admin-password> {base_url}/api/v1/admin/dashboard"
|
||||
admin_history_example = f"curl -sS -u admin:<admin-password> '{base_url}/api/v1/admin/events?limit=25'"
|
||||
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
|
||||
return _page(
|
||||
"KarlDNS API",
|
||||
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">API</p>
|
||||
<h1>Small API surface.</h1>
|
||||
<p class="lead">Three surfaces: admin API, user dashboard data, and router registration/update calls.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section-compact">
|
||||
<div class="container narrow d-grid gap-4">
|
||||
<div class="api-endpoints">
|
||||
<h2>Endpoints</h2>
|
||||
<ol class="simple-steps text-start">
|
||||
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and one-time secrets. The response includes <code>registration_url</code>.</li>
|
||||
<li><strong>GET or POST /api/v1/router/register/<secret>.</strong> Register the hostname after secrets are saved. Uses the request IP, or <code>myip</code>/<code>myipv6</code>, for the initial DNS record.</li>
|
||||
<li><strong>GET or POST /api/v1/router/update/<secret>.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
|
||||
<li><strong>GET /api/v1/hostnames/<secret>.</strong> Read hostname status and recent history.</li>
|
||||
<li><strong>POST /api/v1/hostnames/<secret>/rotate-update-url.</strong> Replace the private router update URL.</li>
|
||||
<li><strong>DELETE /api/v1/hostnames/<secret>.</strong> Delete hostname and DNS records.</li>
|
||||
<li><strong>GET /api/v1/admin/dashboard.</strong> Read operator summary, metrics, recent hostnames, cleanup state, and recent update history with admin Basic auth.</li>
|
||||
<li><strong>GET /api/v1/admin/events.</strong> Read paginated update history with admin Basic auth. Also available: <code>/api/v1/admin/hostnames</code> and <code>/api/v1/admin/records</code>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Create</h2>
|
||||
{_code_window(create_example, copy_label="create command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Register</h2>
|
||||
{_code_window(register_example, copy_label="register command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Update</h2>
|
||||
{_code_window(update_example, copy_label="update command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Status</h2>
|
||||
<p class="section-copy">Use the private dashboard secret to read current DNS state and recent updates for only that hostname.</p>
|
||||
{_code_window(status_example, copy_label="status command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Rotate update URL</h2>
|
||||
<p class="section-copy">Use the private dashboard secret to replace the router update URL. The old update URL stops working immediately.</p>
|
||||
{_code_window(rotate_example, copy_label="rotate command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Schema</h2>
|
||||
<p class="section-copy">Machine-readable OpenAPI is still available. Operator API routes are included and require admin Basic auth.</p>
|
||||
{_code_window(schema_example, copy_label="schema command", class_name="mt-3")}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Operator API</h2>
|
||||
<p class="section-copy">Admin endpoints use the same HTTP Basic auth as the operator console.</p>
|
||||
{_code_window(admin_dashboard_example, copy_label="admin dashboard command", class_name="mt-3")}
|
||||
{_code_window(admin_history_example, copy_label="admin history command", class_name="mt-3")}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""),
|
||||
description=description,
|
||||
canonical_url=f"{base_url}/docs",
|
||||
image_url=f"{base_url}/social-preview.png",
|
||||
image_alt="KarlDNS API documentation",
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..security import dashboard_csrf_token
|
||||
from ..services.ddns import DdnsService
|
||||
from .components import (
|
||||
_code_window,
|
||||
_copy_menu,
|
||||
_copy_row,
|
||||
_fritzbox_fields,
|
||||
_manual_update_commands,
|
||||
_manual_update_panel,
|
||||
_one_time_secret_notice,
|
||||
_secret_notice,
|
||||
)
|
||||
from .html_utils import SafeHtml, attr, escape, trusted
|
||||
|
||||
|
||||
def _linux_installer_section(base_url: str) -> SafeHtml:
|
||||
base_url = base_url.rstrip("/")
|
||||
system_install = f"curl -fsSL {base_url}/install.sh | sudo sh"
|
||||
system_uninstall = f"curl -fsSL {base_url}/uninstall.sh | sudo sh"
|
||||
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>Linux updater.</h2>
|
||||
<p class="section-copy mx-auto">Run this on a Linux box that is always on. It creates and registers a hostname, installs the updater, schedules cron, and prints your private dashboard link. Works with Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts.</p>
|
||||
</div>
|
||||
<div class="installer-commands">
|
||||
<div class="installer-command">
|
||||
<h3>Install</h3>
|
||||
<p class="section-copy">Sets up the updater and cron job.</p>
|
||||
{_code_window(system_install, copy_label="install command", class_name="mt-3")}
|
||||
</div>
|
||||
<div class="installer-command">
|
||||
<h3>Uninstall</h3>
|
||||
<p class="section-copy">Stops the updater and leaves private URLs on disk.</p>
|
||||
{_code_window(system_uninstall, copy_label="uninstall command", class_name="mt-3")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _created_hostname_panel(service: DdnsService, created_account: dict[str, str] | None, request_ip: str | None = None) -> SafeHtml:
|
||||
if not created_account:
|
||||
return trusted("")
|
||||
hostname = created_account["hostname"]
|
||||
update_url = service.fritz_update_url(created_account)
|
||||
management_url = service.dashboard_url(created_account)
|
||||
registration_url = service.registration_url(created_account)
|
||||
csrf_token = dashboard_csrf_token(created_account["management_slug"], created_account)
|
||||
management_slug = attr(created_account["management_slug"])
|
||||
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
|
||||
fritzbox_fields = _fritzbox_fields(update_url, hostname, management_url)
|
||||
manual_commands = _manual_update_commands(update_url, request_ip)
|
||||
return trusted(
|
||||
f"""
|
||||
<section class="section success-section">
|
||||
<div class="container">
|
||||
<p class="eyebrow">Pending setup</p>
|
||||
<h2>{escape(hostname)}</h2>
|
||||
<p class="section-copy">Copy these values now. Then register the hostname so router updates can start. Unregistered setups are removed after 24 hours.</p>
|
||||
{_one_time_secret_notice()}
|
||||
{_secret_notice(include_registration=True)}
|
||||
<div class="copy-actions my-3">
|
||||
{_copy_menu("Copy setup values", [("Router fields", fritzbox_fields), ("Register URL", registration_url), ("CNAME", cname_record)])}
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
{_copy_row("Update-URL:", update_url, "Secret")}
|
||||
{_copy_row("Register URL:", registration_url, "Secret")}
|
||||
{_copy_row("Domainnamen:", hostname, "Public")}
|
||||
{_copy_row("CNAME target:", hostname, "Public")}
|
||||
{_copy_row("Private dashboard:", management_url, "Secret")}
|
||||
</div>
|
||||
<form class="mt-4" method="post" action="/m/{management_slug}/activate">
|
||||
<input type="hidden" name="csrf" value="{attr(csrf_token)}">
|
||||
<button class="btn btn-primary rounded-pill" type="submit">Register hostname</button>
|
||||
</form>
|
||||
{_manual_update_panel(manual_commands)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
)
|
||||