Archived
Prepare KarlDNS production deployment
This commit is contained in:
1 parent
5a90bd9d87
commit
ce5b4e0ff2
101 files changed
+3364
-2018
No files matched your search
Whitespace-only changes.
@@ -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 {}
|
||||
Reference in new issue
Block a user