Prepare KarlDNS production deployment

This commit is contained in:
StasonJatham committed 2026-05-21 17:23:08 +02:00
1 parent 5a90bd9d87
commit ce5b4e0ff2
101 files changed
+3364 -2018

No files matched your search

View File
Whitespace-only changes.
+176
View File
@@ -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()
+223
View File
@@ -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)
+141
View File
@@ -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,
)
+209
View File
@@ -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")
+163
View File
@@ -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
+146
View File
@@ -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
+28
View File
@@ -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.",
),
]
+77
View File
@@ -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")
+199
View File
@@ -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 {}