Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+14 -8
View File
@@ -19,6 +19,7 @@ from ..web.templates import _copy_row
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:
@@ -42,6 +43,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
store.list_scheduled_tasks(),
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
store.get_service_metrics(),
{
"accounts": store.count_accounts(),
"domain_challenges": store.count_domain_challenges(),
@@ -131,7 +133,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"router update URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
notice = _admin_secret_panel("Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
elif action == "management":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool(
@@ -143,7 +145,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"management URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Private status page rotated", [_copy_row("Private status page:", service.magic_management_url(rotated))]) if rotated else None
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.magic_management_url(rotated))]) if rotated else None
elif action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
await run_in_threadpool(
@@ -157,10 +159,10 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
)
if rotated:
notice = _admin_secret_panel(
"Router password rotated",
"Router password changed",
[
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
_copy_row("One-box update URL:", service.credentialed_update_url(rotated), "Secret"),
_copy_row("Full update URL:", service.credentialed_update_url(rotated), "Secret"),
_copy_row("Benutzername (optional):", str(rotated["username"]), "Public"),
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
],
@@ -187,7 +189,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
settings.cleanup_event_history_hours,
)
notice = _admin_secret_panel(
"Cleanup completed",
"Cleanup done",
[
_copy_row("Domain claims removed", str(result["domain_challenges"])),
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
@@ -202,7 +204,7 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
) -> dict[str, object]:
return {
"items": store.list_records_page(limit=limit, offset=offset),
@@ -216,12 +218,16 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
before: str | None = Query(None, max_length=64),
) -> dict[str, object]:
items = store.list_update_events_before(limit=limit, before=before) if before else store.list_update_events_page(limit=limit, offset=offset)
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
return {
"items": store.list_update_events_page(limit=limit, offset=offset),
"items": items,
"limit": limit,
"offset": offset,
"next_before": next_before,
"total": store.count_update_events(),
}
+13 -12
View File
@@ -22,6 +22,7 @@ from ..schemas import (
_credentials_response,
_managed_record,
)
from .path_params import ManagementSlugPath, UpdateSlugPath
router = APIRouter(prefix="/api/v1")
@@ -30,7 +31,7 @@ router = APIRouter(prefix="/api/v1")
"/hostnames/magic",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate a provider-owned DynDNS hostname",
summary="Create a KarlDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
@@ -42,12 +43,12 @@ async def create_magic_hostname(payload: MagicHostnameRequest, service: ServiceD
@router.get(
"/management/{management_slug}",
response_model=ManagedRecord,
summary="Read a hostname by magic management link",
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
summary="Read a hostname by private dashboard link",
description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
tags=["management"],
responses={404: {"model": ErrorResponse}},
)
async def read_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> ManagedRecord:
async def read_managed_hostname(management_slug: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> ManagedRecord:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
@@ -57,12 +58,12 @@ async def read_managed_hostname(management_slug: str, store: StoreDep, service:
@router.delete(
"/management/{management_slug}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by magic management link",
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
summary="Delete a hostname by private dashboard link",
description="Uses the private dashboard link secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
tags=["management"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> Response:
async def delete_managed_hostname(management_slug: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
@@ -70,7 +71,7 @@ async def delete_managed_hostname(management_slug: str, store: StoreDep, service
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
raise HTTPException(status_code=500, detail="DNS delete failed. Hostname was not deleted.") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -116,7 +117,7 @@ async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> Ve
return VerifyDomainResponse(
domain=domain,
verified=True,
message="domain verified",
message="Domain verified",
claim_secret=payload.claim_secret,
)
@@ -125,7 +126,7 @@ async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> Ve
"/hostnames/custom",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate credentials for a verified custom hostname",
summary="Create a hostname for a verified domain",
tags=["hostnames"],
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
@@ -147,7 +148,7 @@ async def create_custom_hostname(payload: CustomHostnameRequest, service: Servic
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def update_hostname(
update_slug: str,
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
@@ -174,6 +175,6 @@ async def update_hostname(
except BadIpError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
raise HTTPException(status_code=500, detail="DNS update failed") from exc
result = outcome.result
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
+60 -10
View File
@@ -5,13 +5,13 @@ from pathlib import Path
from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse
from ..web.styles import APP_CSS
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=3600"}
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
APP_JS = """
THEME_JS = """
(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const storageKey = "karldns-theme";
@@ -30,8 +30,9 @@ APP_JS = """
};
const syncToggle = (theme) => {
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.textContent = theme === "dark" ? "Dark" : "Light";
button.setAttribute("aria-label", `Switch to ${theme === "dark" ? "light" : "dark"} theme`);
const nextTheme = theme === "dark" ? "light" : "dark";
button.dataset.themeNext = nextTheme;
button.setAttribute("aria-label", `Switch to ${nextTheme.toLowerCase()} theme`);
});
};
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
@@ -54,7 +55,10 @@ APP_JS = """
applyTheme(nextTheme);
});
})();
""".strip()
CLIPBOARD_JS = """
document.addEventListener("click", async (event) => {
const toggle = event.target.closest("[data-secret-toggle]");
if (toggle) {
@@ -87,7 +91,10 @@ document.addEventListener("click", async (event) => {
}, 1200);
} catch (_) {}
});
""".strip()
ADMIN_TABLES_JS = """
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
const table = tableShell.querySelector("table");
const tbody = tableShell.querySelector("tbody");
@@ -176,10 +183,22 @@ document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
""".strip()
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)
@@ -196,6 +215,14 @@ def apple_touch_icon_png() -> FileResponse:
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)
@@ -204,6 +231,14 @@ def logo_192_png() -> FileResponse:
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(
@@ -211,7 +246,7 @@ def site_webmanifest() -> Response:
{
"name": "KarlDNS",
"short_name": "KarlDNS",
"description": "Accountless Dynamic DNS for routers and home labs.",
"description": "Private update links for routers.",
"start_url": "/",
"scope": "/",
"display": "standalone",
@@ -227,14 +262,29 @@ def site_webmanifest() -> Response:
headers=ASSET_CACHE_HEADERS,
)
@router.get("/app.js")
def app_js() -> Response:
return Response(APP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
@router.get("/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(APP_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
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")
+11 -10
View File
@@ -26,6 +26,7 @@ from .management_shared import (
locked_management_response,
render_management_page_async,
)
from .path_params import ManagementSlugPath
router = APIRouter()
router.include_router(management_passwords.router)
@@ -33,24 +34,24 @@ router.include_router(management_domains.router)
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
def manage_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
def manage_magic(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account)
if not has_management_access(request, management_slug, account):
if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token)
return _render_management_page(service, settings, management_slug, account, csrf_token)
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
async def rotate_magic_credentials(
management_slug: str,
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
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"))
@@ -75,7 +76,7 @@ async def rotate_magic_credentials(
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Update-URL rotated.",
message="Update URL changed. Update your router now.",
)
if action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
@@ -97,16 +98,16 @@ async def rotate_magic_credentials(
management_slug,
rotated,
dashboard_csrf_token(management_slug, rotated),
message="Router credentials rotated. Copy the new password now.",
message="Router password changed. Copy it now.",
created_account=rotated,
)
raise _bad_rotation_action()
@router.post("/m/{management_slug}/delete", include_in_schema=False)
async def delete_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
async def delete_magic(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, management_slug, account):
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"))
@@ -114,7 +115,7 @@ async def delete_magic(management_slug: str, request: Request, settings: Setting
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)
return PlainTextResponse("DNS delete failed. Hostname was not deleted.", status_code=500)
await run_in_threadpool(
store.log_update_event,
hostname,
@@ -126,7 +127,7 @@ async def delete_magic(management_slug: str, request: Request, settings: Setting
)
return HTMLResponse(
_page(
"DynDNS deleted",
"Hostname deleted",
trusted(
"""
<main>
+11 -10
View File
@@ -23,12 +23,13 @@ from .management_shared import (
locked_management_response,
render_management_page_async,
)
from .path_params import ClaimSecretPath, ManagementSlugPath
router = APIRouter()
@router.get("/d/{claim_secret}", response_class=HTMLResponse, include_in_schema=False)
async def manage_domain_claim(claim_secret: str, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
async def manage_domain_claim(claim_secret: ClaimSecretPath, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
challenge = await run_in_threadpool(store.get_domain_challenge_by_secret, claim_secret)
if not challenge:
raise HTTPException(status_code=404, detail="not found")
@@ -37,14 +38,14 @@ async def manage_domain_claim(claim_secret: str, settings: SettingsDep, store: S
@router.post("/m/{management_slug}/domains/request", response_class=HTMLResponse, include_in_schema=False)
async def request_dashboard_domain(
management_slug: str,
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
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"))
@@ -71,28 +72,28 @@ async def request_dashboard_domain(
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Domain claim created. Add the TXT record, then verify it.",
message="Claim created. Add the TXT record, then verify it.",
challenge=challenge,
)
@router.post("/m/{management_slug}/domains/verify", response_class=HTMLResponse, include_in_schema=False)
async def verify_dashboard_domain(
management_slug: str,
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
if not has_management_access(request, settings, management_slug, account):
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
domain = normalize_domain(first_form_value(form, "domain"))
claim_secret = first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, str(account["hostname"]))
message = "Domain verified. You can now create a custom hostname." if found else "TXT record not found yet. DNS propagation can take a few minutes."
message = "Domain verified. You can create a hostname now." if found else "TXT record not found yet. Try again in a few minutes."
await run_in_threadpool(
store.log_update_event,
str(account["hostname"]),
@@ -115,14 +116,14 @@ async def verify_dashboard_domain(
@router.post("/m/{management_slug}/domains/hostnames", response_class=HTMLResponse, include_in_schema=False)
async def create_dashboard_custom_hostname(
management_slug: str,
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, management_slug, account):
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"))
@@ -148,6 +149,6 @@ async def create_dashboard_custom_hostname(
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Custom hostname created. Copy these router settings now.",
message="Hostname created. Copy these router settings now.",
created_account=created,
)
+11 -10
View File
@@ -18,12 +18,13 @@ from .management_shared import (
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_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
async def unlock_magic(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)
@@ -34,7 +35,7 @@ async def unlock_magic(management_slug: str, request: Request, settings: Setting
is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password)
if not is_valid:
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect dashboard password."),
_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)
@@ -50,14 +51,14 @@ async def unlock_magic(management_slug: str, request: Request, settings: Setting
@router.post("/m/{management_slug}/password", include_in_schema=False)
async def set_dashboard_password(
management_slug: str,
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, management_slug, account):
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"))
@@ -69,7 +70,7 @@ async def set_dashboard_password(
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.")
password = first_form_value(form, "dashboard_password")
confirm = first_form_value(form, "dashboard_password_confirm")
if len(password) < 8:
@@ -79,7 +80,7 @@ async def set_dashboard_password(
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Dashboard password must be at least 8 characters.",
message="Password must be at least 8 characters.",
)
return HTMLResponse(page, status_code=400)
if password != confirm:
@@ -89,7 +90,7 @@ async def set_dashboard_password(
management_slug,
account,
dashboard_csrf_token(management_slug, account),
message="Dashboard password confirmation did not match.",
message="The passwords do not match.",
)
return HTMLResponse(page, status_code=400)
had_password = bool(account.get("dashboard_password_hash"))
@@ -111,14 +112,14 @@ async def set_dashboard_password(
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
async def remove_dashboard_password(
management_slug: str,
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, management_slug, account):
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"))
@@ -130,7 +131,7 @@ async def remove_dashboard_password(
first_form_value(form, "current_dashboard_password"),
)
if not is_current:
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
return await management_error_response_async(service, settings, management_slug, account, "Current password is incorrect.")
await run_in_threadpool(store.clear_dashboard_password, hostname)
await run_in_threadpool(
store.log_update_event,
+12 -3
View File
@@ -7,6 +7,7 @@ from starlette.concurrency import run_in_threadpool
from ..ddns_models import DdnsSettings
from ..ddns_security import (
DASHBOARD_UNLOCK_COOKIE,
dashboard_cookie_signing_secret,
dashboard_csrf_token,
sign_dashboard_unlock_cookie,
verify_dashboard_unlock_cookie,
@@ -30,7 +31,7 @@ async def get_active_management_account_async(store: DdnsStore, management_slug:
return await run_in_threadpool(get_active_management_account, store, management_slug)
def has_management_access(request: Request, management_slug: str, account: ManagementAccount) -> bool:
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
@@ -38,6 +39,7 @@ def has_management_access(request: Request, management_slug: str, account: Manag
str(account["hostname"]),
management_slug,
str(password_hash),
dashboard_cookie_signing_secret(settings),
request.cookies.get(DASHBOARD_UNLOCK_COOKIE),
)
@@ -100,11 +102,18 @@ def set_unlock_cookie(
max_age_seconds: int,
) -> None:
password_hash = account.get("dashboard_password_hash")
if not 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), max_age_seconds),
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,
+37
View File
@@ -0,0 +1,37 @@
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.",
),
]
ClaimSecretPath = Annotated[
str,
Path(
min_length=TOKEN_MIN_LENGTH,
max_length=TOKEN_MAX_LENGTH,
pattern=TOKEN_URLSAFE_PATTERN,
description="Private domain claim value.",
),
]
+47 -5
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
from starlette.concurrency import run_in_threadpool
from ..ddns_security import client_ip
@@ -52,9 +52,9 @@ async def verify_domain(request: Request, settings: SettingsDep, service: Servic
claim_secret = first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
if not found:
message = "TXT record not found yet. DNS propagation can take a few minutes."
message = "TXT record not found yet. Try again in a few minutes."
return _render_public_home(service, settings, None, message, challenge)
return _render_public_home(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge)
return _render_public_home(service, settings, None, "Domain verified. You can create a hostname now.", challenge)
@router.post("/accounts", response_class=HTMLResponse, include_in_schema=False)
@@ -85,5 +85,47 @@ def visible_ip(request: Request, settings: SettingsDep) -> str:
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
def privacy() -> str:
return _render_privacy_page()
def privacy(settings: SettingsDep) -> str:
return _render_privacy_page(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: /d/",
"Disallow: /u/",
"Disallow: /nic/",
"Disallow: /api/",
"Disallow: /docs",
"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")
+2 -1
View File
@@ -6,13 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update
from .path_params import UpdateSlugPath
router = APIRouter()
@router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
def slug_update(
update_slug: str,
update_slug: UpdateSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,