Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_dns import DnsProviderError
|
||||
from ..ddns_security import client_ip, require_admin_csrf
|
||||
from ..ddns_service import normalize_hostname
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
|
||||
from ..event_statuses import (
|
||||
ADMIN_HOSTNAME_DELETED,
|
||||
ADMIN_MANAGEMENT_URL_ROTATED,
|
||||
ADMIN_ROUTER_CREDENTIALS_ROTATED,
|
||||
ADMIN_UPDATE_URL_ROTATED,
|
||||
)
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.templates import _copy_row
|
||||
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
|
||||
|
||||
ADMIN_TABLE_LIMIT = 100
|
||||
|
||||
|
||||
def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
router = APIRouter()
|
||||
records_path = f"{admin_path}/records"
|
||||
events_path = f"{admin_path}/events"
|
||||
|
||||
def render_admin(
|
||||
service: ServiceDep,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
table_offsets: dict[str, int] | None = None,
|
||||
notice: str | None = None,
|
||||
) -> str:
|
||||
table_offsets = table_offsets or {}
|
||||
return _render_admin_page(
|
||||
service,
|
||||
settings,
|
||||
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
|
||||
store.list_domain_challenges(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("domain_challenges", 0)),
|
||||
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
|
||||
store.list_scheduled_tasks(),
|
||||
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
|
||||
{
|
||||
"accounts": store.count_accounts(),
|
||||
"domain_challenges": store.count_domain_challenges(),
|
||||
"cleanup_runs": store.count_cleanup_runs(),
|
||||
"update_events": store.count_update_events(),
|
||||
},
|
||||
table_offsets,
|
||||
ADMIN_TABLE_LIMIT,
|
||||
notice,
|
||||
)
|
||||
|
||||
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
|
||||
def admin(
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
accounts_offset: int = Query(0, ge=0),
|
||||
domain_claims_offset: int = Query(0, ge=0),
|
||||
cleanup_runs_offset: int = Query(0, ge=0),
|
||||
update_events_offset: int = Query(0, ge=0),
|
||||
) -> str:
|
||||
return render_admin(
|
||||
service,
|
||||
settings,
|
||||
store,
|
||||
{
|
||||
"accounts": accounts_offset,
|
||||
"domain_challenges": domain_claims_offset,
|
||||
"cleanup_runs": cleanup_runs_offset,
|
||||
"update_events": update_events_offset,
|
||||
},
|
||||
)
|
||||
|
||||
@router.post(f"{admin_path}/accounts/delete", include_in_schema=False)
|
||||
async def admin_delete_account(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
) -> Response:
|
||||
form = await request_form(request, settings)
|
||||
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
||||
hostname = normalize_hostname(first_form_value(form, "hostname"), settings, expand_suffix=False)
|
||||
if hostname:
|
||||
try:
|
||||
await run_in_threadpool(service.delete_account, hostname, "admin")
|
||||
except DnsProviderError:
|
||||
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
ADMIN_HOSTNAME_DELETED,
|
||||
"hostname deleted by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return RedirectResponse(admin_path, status_code=303)
|
||||
|
||||
@router.post(f"{admin_path}/accounts/rotate", include_in_schema=False)
|
||||
async def admin_rotate_account(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
) -> Response:
|
||||
form = await request_form(request, settings)
|
||||
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
||||
hostname = normalize_hostname(first_form_value(form, "hostname"), settings, expand_suffix=False)
|
||||
action = first_form_value(form, "action")
|
||||
account = await run_in_threadpool(store.get_account_by_hostname, hostname)
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="hostname not found")
|
||||
|
||||
notice = None
|
||||
if action == "update":
|
||||
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
ADMIN_UPDATE_URL_ROTATED,
|
||||
"router update URL rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
|
||||
elif action == "management":
|
||||
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
ADMIN_MANAGEMENT_URL_ROTATED,
|
||||
"management URL rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
notice = _admin_secret_panel("Private status page rotated", [_copy_row("Private status page:", service.magic_management_url(rotated))]) if rotated else None
|
||||
elif action == "password":
|
||||
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
ADMIN_ROUTER_CREDENTIALS_ROTATED,
|
||||
"router credentials rotated by admin",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
if rotated:
|
||||
notice = _admin_secret_panel(
|
||||
"Router password rotated",
|
||||
[
|
||||
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
|
||||
_copy_row("One-box update URL:", service.credentialed_update_url(rotated), "Secret"),
|
||||
_copy_row("Benutzername (optional):", str(rotated["username"]), "Public"),
|
||||
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
|
||||
],
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="invalid rotation action")
|
||||
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
|
||||
return HTMLResponse(page)
|
||||
|
||||
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
|
||||
async def admin_run_cleanup(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
) -> Response:
|
||||
form = await request_form(request, settings)
|
||||
require_admin_csrf(settings, first_form_value(form, "csrf"))
|
||||
result = await run_in_threadpool(
|
||||
store.cleanup,
|
||||
settings.cleanup_challenge_hours,
|
||||
settings.cleanup_unused_account_hours,
|
||||
settings.cleanup_event_history_hours,
|
||||
)
|
||||
notice = _admin_secret_panel(
|
||||
"Cleanup completed",
|
||||
[
|
||||
_copy_row("Domain claims removed", str(result["domain_challenges"])),
|
||||
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
|
||||
_copy_row("Old update events removed", str(result["update_events"])),
|
||||
],
|
||||
)
|
||||
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
|
||||
return HTMLResponse(page)
|
||||
|
||||
@router.get(records_path, include_in_schema=False)
|
||||
def records(
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"items": store.list_records_page(limit=limit, offset=offset),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"total": store.count_records(),
|
||||
}
|
||||
|
||||
@router.get(events_path, include_in_schema=False)
|
||||
def events(
|
||||
store: StoreDep,
|
||||
_: None = Depends(authenticate_admin),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"items": store.list_update_events_page(limit=limit, offset=offset),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"total": store.count_update_events(),
|
||||
}
|
||||
|
||||
if admin_path == "/admin":
|
||||
router.add_api_route("/records", records, methods=["GET"], include_in_schema=False)
|
||||
router.add_api_route("/events", events, methods=["GET"], include_in_schema=False)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = make_router()
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_dns import DnsProviderError
|
||||
from ..ddns_service import BadIpError, normalize_domain
|
||||
from ..dependencies import ServiceDep, StoreDep
|
||||
from ..schemas import (
|
||||
CustomHostnameRequest,
|
||||
DomainChallengeRequest,
|
||||
DomainChallengeResponse,
|
||||
ErrorResponse,
|
||||
HostnameCredentials,
|
||||
MagicHostnameRequest,
|
||||
ManagedRecord,
|
||||
RouterUpdateResponse,
|
||||
VerifyDomainRequest,
|
||||
VerifyDomainResponse,
|
||||
_credentials_response,
|
||||
_managed_record,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/hostnames/magic",
|
||||
response_model=HostnameCredentials,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Generate a provider-owned DynDNS hostname",
|
||||
tags=["hostnames"],
|
||||
responses={500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_magic_hostname(payload: MagicHostnameRequest, service: ServiceDep) -> HostnameCredentials:
|
||||
account = await run_in_threadpool(service.create_managed_account, payload.username)
|
||||
return _credentials_response(service, account)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/management/{management_slug}",
|
||||
response_model=ManagedRecord,
|
||||
summary="Read a hostname by magic management link",
|
||||
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
|
||||
tags=["management"],
|
||||
responses={404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def read_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> ManagedRecord:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _managed_record(service, account)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/management/{management_slug}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a hostname by magic management link",
|
||||
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
|
||||
tags=["management"],
|
||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def delete_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> Response:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
hostname = str(account["hostname"])
|
||||
try:
|
||||
await run_in_threadpool(service.delete_account, hostname, "api")
|
||||
except DnsProviderError as exc:
|
||||
raise HTTPException(status_code=500, detail="DNS delete failed; hostname was not deleted") from exc
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/domains/challenges",
|
||||
response_model=DomainChallengeResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a DNS TXT domain verification challenge",
|
||||
tags=["domains"],
|
||||
responses={400: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_domain_challenge(payload: DomainChallengeRequest, service: ServiceDep) -> DomainChallengeResponse:
|
||||
domain = normalize_domain(payload.domain)
|
||||
try:
|
||||
challenge = await run_in_threadpool(service.create_domain_challenge, domain)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
||||
return DomainChallengeResponse(
|
||||
domain=domain,
|
||||
txt_name=service.verification_name(domain),
|
||||
txt_value=challenge["token"],
|
||||
claim_secret=challenge["claim_secret"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/domains/verify",
|
||||
response_model=VerifyDomainResponse,
|
||||
summary="Verify a domain TXT challenge",
|
||||
tags=["domains"],
|
||||
responses={404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def verify_domain(payload: VerifyDomainRequest, service: ServiceDep) -> VerifyDomainResponse:
|
||||
domain = normalize_domain(payload.domain)
|
||||
found, _challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret)
|
||||
if not found:
|
||||
return VerifyDomainResponse(
|
||||
domain=domain,
|
||||
verified=False,
|
||||
message="TXT record not found yet",
|
||||
claim_secret=payload.claim_secret,
|
||||
)
|
||||
return VerifyDomainResponse(
|
||||
domain=domain,
|
||||
verified=True,
|
||||
message="domain verified",
|
||||
claim_secret=payload.claim_secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/hostnames/custom",
|
||||
response_model=HostnameCredentials,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Generate credentials for a verified custom hostname",
|
||||
tags=["hostnames"],
|
||||
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_custom_hostname(payload: CustomHostnameRequest, service: ServiceDep) -> HostnameCredentials:
|
||||
account = await run_in_threadpool(
|
||||
service.create_custom_account,
|
||||
payload.hostname,
|
||||
payload.claim_secret,
|
||||
payload.username,
|
||||
)
|
||||
return _credentials_response(service, account)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/updates/{update_slug}",
|
||||
response_model=RouterUpdateResponse,
|
||||
summary="Router-compatible update endpoint",
|
||||
tags=["updates"],
|
||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def update_hostname(
|
||||
update_slug: str,
|
||||
request: Request,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
myip: str | None = None,
|
||||
ipaddr: str | None = None,
|
||||
myipv6: str | None = None,
|
||||
ip6addr: str | None = None,
|
||||
) -> RouterUpdateResponse:
|
||||
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
hostname = str(account["hostname"])
|
||||
try:
|
||||
outcome = await run_in_threadpool(
|
||||
service.apply_update,
|
||||
request,
|
||||
hostname,
|
||||
myip,
|
||||
ipaddr,
|
||||
myipv6,
|
||||
ip6addr,
|
||||
)
|
||||
except BadIpError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except DnsProviderError as exc:
|
||||
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
|
||||
result = outcome.result
|
||||
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
|
||||
from ..web.styles import APP_CSS
|
||||
|
||||
ASSET_DIR = Path(__file__).parents[1] / "assets"
|
||||
router = APIRouter(include_in_schema=False)
|
||||
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"}
|
||||
|
||||
APP_JS = """
|
||||
(() => {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const storageKey = "karldns-theme";
|
||||
const systemTheme = () => (media.matches ? "dark" : "light");
|
||||
const storedTheme = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const setStoredTheme = (theme) => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, theme);
|
||||
} catch (_) {}
|
||||
};
|
||||
const syncToggle = (theme) => {
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
|
||||
button.textContent = theme === "dark" ? "Dark" : "Light";
|
||||
button.setAttribute("aria-label", `Switch to ${theme === "dark" ? "light" : "dark"} theme`);
|
||||
});
|
||||
};
|
||||
const applyTheme = (requestedTheme = storedTheme() || systemTheme()) => {
|
||||
const theme = requestedTheme === "dark" ? "dark" : "light";
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
syncToggle(theme);
|
||||
return theme;
|
||||
};
|
||||
applyTheme();
|
||||
media.addEventListener("change", () => {
|
||||
if (!storedTheme()) applyTheme();
|
||||
});
|
||||
document.addEventListener("DOMContentLoaded", () => syncToggle(document.documentElement.getAttribute("data-bs-theme") || "light"));
|
||||
document.addEventListener("click", (event) => {
|
||||
const toggle = event.target.closest("[data-theme-toggle]");
|
||||
if (!toggle) return;
|
||||
const currentTheme = document.documentElement.getAttribute("data-bs-theme") || systemTheme();
|
||||
const nextTheme = currentTheme === "dark" ? "light" : "dark";
|
||||
setStoredTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
});
|
||||
})();
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const toggle = event.target.closest("[data-secret-toggle]");
|
||||
if (toggle) {
|
||||
const group = toggle.closest(".copy-field");
|
||||
const field = group ? group.querySelector("[data-secret-input], [data-secret-value]") : null;
|
||||
if (!field) return;
|
||||
if (field.matches("[data-secret-input]")) {
|
||||
const masked = field.type === "password";
|
||||
field.type = masked ? "text" : "password";
|
||||
toggle.textContent = masked ? "Hide" : "View";
|
||||
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
|
||||
} else {
|
||||
const masked = field.dataset.secretMasked !== "false";
|
||||
field.dataset.secretMasked = masked ? "false" : "true";
|
||||
field.textContent = masked ? (field.dataset.secretValue || "") : "********";
|
||||
toggle.textContent = masked ? "Hide" : "View";
|
||||
toggle.setAttribute("aria-label", masked ? "Hide secret value" : "Show secret value");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const button = event.target.closest("[data-copy]");
|
||||
if (!button) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(button.dataset.copy || "");
|
||||
const old = button.textContent;
|
||||
button.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
button.textContent = old;
|
||||
}, 1200);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-admin-table]").forEach((tableShell) => {
|
||||
const table = tableShell.querySelector("table");
|
||||
const tbody = tableShell.querySelector("tbody");
|
||||
const search = tableShell.querySelector("[data-table-search]");
|
||||
const pageSize = tableShell.querySelector("[data-table-page-size]");
|
||||
const count = tableShell.querySelector("[data-table-count]");
|
||||
const pagination = tableShell.querySelector("[data-table-pagination]");
|
||||
if (!table || !tbody || !search || !pageSize || !count || !pagination) return;
|
||||
|
||||
const headerCount = table.querySelectorAll("thead th").length || 1;
|
||||
const originalRows = Array.from(tbody.querySelectorAll("tr"));
|
||||
const dataRows = originalRows.filter((row) => !row.classList.contains("empty-row"));
|
||||
let currentPage = 1;
|
||||
|
||||
const emptyRow = (message) => {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "empty-row";
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "empty";
|
||||
cell.colSpan = headerCount;
|
||||
cell.textContent = message;
|
||||
row.append(cell);
|
||||
return row;
|
||||
};
|
||||
|
||||
const visibleRows = () => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
if (!query) return dataRows;
|
||||
return dataRows.filter((row) => row.textContent.toLowerCase().includes(query));
|
||||
};
|
||||
|
||||
const renderButton = (label, page, disabled = false, active = false) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = `page-item${disabled ? " disabled" : ""}${active ? " active" : ""}`;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "page-link";
|
||||
button.textContent = label;
|
||||
button.disabled = disabled;
|
||||
button.addEventListener("click", () => {
|
||||
currentPage = page;
|
||||
render();
|
||||
});
|
||||
item.append(button);
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderPagination = (totalPages) => {
|
||||
pagination.replaceChildren();
|
||||
if (totalPages <= 1) return;
|
||||
pagination.append(renderButton("Previous", Math.max(1, currentPage - 1), currentPage === 1));
|
||||
for (let page = 1; page <= totalPages; page += 1) {
|
||||
if (totalPages > 7 && page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) {
|
||||
if (page === 2 || page === totalPages - 1) pagination.append(renderButton("...", currentPage, true));
|
||||
continue;
|
||||
}
|
||||
pagination.append(renderButton(String(page), page, false, page === currentPage));
|
||||
}
|
||||
pagination.append(renderButton("Next", Math.min(totalPages, currentPage + 1), currentPage === totalPages));
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const rows = visibleRows();
|
||||
const size = Number.parseInt(pageSize.value, 10) || 25;
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / size));
|
||||
currentPage = Math.min(currentPage, totalPages);
|
||||
const start = (currentPage - 1) * size;
|
||||
const pageRows = rows.slice(start, start + size);
|
||||
tbody.replaceChildren(...(pageRows.length ? pageRows : [emptyRow(dataRows.length ? "No matching rows." : "No rows yet.")]));
|
||||
const shownStart = rows.length ? start + 1 : 0;
|
||||
const shownEnd = rows.length ? Math.min(start + size, rows.length) : 0;
|
||||
count.textContent = `${shownStart}-${shownEnd} of ${rows.length}`;
|
||||
renderPagination(totalPages);
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
currentPage = 1;
|
||||
render();
|
||||
});
|
||||
pageSize.addEventListener("change", () => {
|
||||
currentPage = 1;
|
||||
render();
|
||||
});
|
||||
render();
|
||||
});
|
||||
""".strip()
|
||||
|
||||
|
||||
@router.get("/favicon.ico")
|
||||
def favicon() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/favicon-16x16.png")
|
||||
def favicon_16_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/favicon-32x32.png")
|
||||
def favicon_32_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/apple-touch-icon.png")
|
||||
def apple_touch_icon_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo.png")
|
||||
def logo_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-192.png")
|
||||
def logo_192_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/logo-512.png")
|
||||
def logo_512_png() -> FileResponse:
|
||||
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
@router.get("/site.webmanifest")
|
||||
def site_webmanifest() -> Response:
|
||||
return Response(
|
||||
"""
|
||||
{
|
||||
"name": "KarlDNS",
|
||||
"short_name": "KarlDNS",
|
||||
"description": "Accountless Dynamic DNS for routers and home labs.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
""".strip(),
|
||||
media_type="application/manifest+json",
|
||||
headers=ASSET_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
@router.get("/app.js")
|
||||
def app_js() -> Response:
|
||||
return Response(APP_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/app.css")
|
||||
def app_css() -> Response:
|
||||
return Response(APP_CSS, media_type="text/css", headers=ASSET_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/install.sh")
|
||||
def install_sh() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/uninstall.sh")
|
||||
def uninstall_sh() -> RedirectResponse:
|
||||
return RedirectResponse(
|
||||
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
|
||||
status_code=302,
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_dns import DnsProviderError
|
||||
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
||||
from ..event_statuses import (
|
||||
DASHBOARD_HOSTNAME_DELETED,
|
||||
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
|
||||
DASHBOARD_UPDATE_URL_ROTATED,
|
||||
)
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.html_utils import trusted
|
||||
from ..web.templates import _page
|
||||
from ..web.templates_management import _render_management_page, _render_management_unlock_page
|
||||
from . import management_domains, management_passwords
|
||||
from .management_shared import (
|
||||
get_active_management_account,
|
||||
get_active_management_account_async,
|
||||
has_management_access,
|
||||
locked_management_response,
|
||||
render_management_page_async,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(management_passwords.router)
|
||||
router.include_router(management_domains.router)
|
||||
|
||||
|
||||
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
|
||||
def manage_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
|
||||
account = get_active_management_account(store, management_slug)
|
||||
csrf_token = dashboard_csrf_token(management_slug, account)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
return _render_management_unlock_page(management_slug, account, csrf_token)
|
||||
return _render_management_page(service, settings, management_slug, account, csrf_token)
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def rotate_magic_credentials(
|
||||
management_slug: str,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
hostname = str(account["hostname"])
|
||||
action = first_form_value(form, "action")
|
||||
if action == "update":
|
||||
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
|
||||
if not rotated:
|
||||
return _not_found()
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_UPDATE_URL_ROTATED,
|
||||
"router update URL rotated",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
rotated,
|
||||
dashboard_csrf_token(management_slug, rotated),
|
||||
message="Update-URL rotated.",
|
||||
)
|
||||
if action == "password":
|
||||
rotated = await run_in_threadpool(store.rotate_password, hostname)
|
||||
if not rotated:
|
||||
return _not_found()
|
||||
rotated["management_slug"] = management_slug
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_ROUTER_CREDENTIALS_ROTATED,
|
||||
"router credentials rotated",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
rotated,
|
||||
dashboard_csrf_token(management_slug, rotated),
|
||||
message="Router credentials rotated. Copy the new password now.",
|
||||
created_account=rotated,
|
||||
)
|
||||
raise _bad_rotation_action()
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/delete", include_in_schema=False)
|
||||
async def delete_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
hostname = str(account["hostname"])
|
||||
try:
|
||||
await run_in_threadpool(service.delete_account, hostname, "management-link")
|
||||
except DnsProviderError:
|
||||
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_HOSTNAME_DELETED,
|
||||
"hostname deleted from dashboard",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return HTMLResponse(
|
||||
_page(
|
||||
"DynDNS deleted",
|
||||
trusted(
|
||||
"""
|
||||
<main>
|
||||
<section class="hero-band compact">
|
||||
<div class="container text-center d-grid justify-items-center gap-3">
|
||||
<p class="eyebrow">Deleted</p>
|
||||
<h1>Hostname deleted.</h1>
|
||||
<p class="lead">The update URL no longer works.</p>
|
||||
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
"""
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _not_found() -> NoReturn:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
|
||||
def _bad_rotation_action() -> NoReturn:
|
||||
raise HTTPException(status_code=400, detail="invalid rotation action")
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
from ..ddns_service import normalize_domain
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
||||
from ..event_statuses import (
|
||||
DASHBOARD_CUSTOM_HOSTNAME_CREATED,
|
||||
DASHBOARD_DOMAIN_CLAIM_CREATED,
|
||||
DASHBOARD_DOMAIN_CLAIM_PENDING,
|
||||
DASHBOARD_DOMAIN_CLAIM_VERIFIED,
|
||||
)
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.templates_public import _render_public_home
|
||||
from .management_shared import (
|
||||
get_active_management_account_async,
|
||||
has_management_access,
|
||||
locked_management_response,
|
||||
render_management_page_async,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/d/{claim_secret}", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def manage_domain_claim(claim_secret: str, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> str:
|
||||
challenge = await run_in_threadpool(store.get_domain_challenge_by_secret, claim_secret)
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return await run_in_threadpool(_render_public_home, service, settings, None, None, challenge)
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/domains/request", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def request_dashboard_domain(
|
||||
management_slug: str,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
try:
|
||||
challenge = await run_in_threadpool(
|
||||
service.create_domain_challenge,
|
||||
first_form_value(form, "domain"),
|
||||
str(account["hostname"]),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
str(account["hostname"]),
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_DOMAIN_CLAIM_CREATED,
|
||||
"domain claim created",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Domain claim created. Add the TXT record, then verify it.",
|
||||
challenge=challenge,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/domains/verify", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def verify_dashboard_domain(
|
||||
management_slug: str,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
domain = normalize_domain(first_form_value(form, "domain"))
|
||||
claim_secret = first_form_value(form, "claim_secret")
|
||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, str(account["hostname"]))
|
||||
message = "Domain verified. You can now create a custom hostname." if found else "TXT record not found yet. DNS propagation can take a few minutes."
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
str(account["hostname"]),
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_DOMAIN_CLAIM_VERIFIED if found else DASHBOARD_DOMAIN_CLAIM_PENDING,
|
||||
"domain claim verified" if found else "domain claim TXT not found",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message=message,
|
||||
challenge=challenge,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/domains/hostnames", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def create_dashboard_custom_hostname(
|
||||
management_slug: str,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> str:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
return locked_management_response(management_slug, account)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
created = await run_in_threadpool(
|
||||
service.create_custom_account,
|
||||
first_form_value(form, "hostname"),
|
||||
first_form_value(form, "claim_secret"),
|
||||
None,
|
||||
str(account["hostname"]),
|
||||
)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
str(account["hostname"]),
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_CUSTOM_HOSTNAME_CREATED,
|
||||
"custom hostname created",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
return await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Custom hostname created. Copy these router settings now.",
|
||||
created_account=created,
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_security import client_ip, dashboard_csrf_token, require_dashboard_csrf
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep
|
||||
from ..event_statuses import DASHBOARD_PASSWORD_CHANGED, DASHBOARD_PASSWORD_ENABLED, DASHBOARD_PASSWORD_REMOVED
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.templates_management import _render_management_unlock_page
|
||||
from .management_shared import (
|
||||
delete_unlock_cookie,
|
||||
get_active_management_account_async,
|
||||
has_management_access,
|
||||
management_error_response_async,
|
||||
render_management_page_async,
|
||||
set_unlock_cookie,
|
||||
unlock_cookie_seconds,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
|
||||
async def unlock_magic(management_slug: str, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not account.get("dashboard_password_hash"):
|
||||
return RedirectResponse(f"/m/{management_slug}", status_code=303)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
password = first_form_value(form, "dashboard_password")
|
||||
hostname = str(account["hostname"])
|
||||
is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password)
|
||||
if not is_valid:
|
||||
return HTMLResponse(
|
||||
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect dashboard password."),
|
||||
status_code=401,
|
||||
)
|
||||
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
|
||||
set_unlock_cookie(
|
||||
response,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
unlock_cookie_seconds(settings, first_form_value(form, "stay_unlocked")),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/password", include_in_schema=False)
|
||||
async def set_dashboard_password(
|
||||
management_slug: str,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
hostname = str(account["hostname"])
|
||||
if account.get("dashboard_password_hash"):
|
||||
is_current = await run_in_threadpool(
|
||||
store.verify_dashboard_password,
|
||||
hostname,
|
||||
first_form_value(form, "current_dashboard_password"),
|
||||
)
|
||||
if not is_current:
|
||||
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
|
||||
password = first_form_value(form, "dashboard_password")
|
||||
confirm = first_form_value(form, "dashboard_password_confirm")
|
||||
if len(password) < 8:
|
||||
page = await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Dashboard password must be at least 8 characters.",
|
||||
)
|
||||
return HTMLResponse(page, status_code=400)
|
||||
if password != confirm:
|
||||
page = await render_management_page_async(
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
dashboard_csrf_token(management_slug, account),
|
||||
message="Dashboard password confirmation did not match.",
|
||||
)
|
||||
return HTMLResponse(page, status_code=400)
|
||||
had_password = bool(account.get("dashboard_password_hash"))
|
||||
await run_in_threadpool(store.set_dashboard_password, hostname, password)
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_PASSWORD_CHANGED if had_password else DASHBOARD_PASSWORD_ENABLED,
|
||||
"dashboard password changed" if had_password else "dashboard password enabled",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
|
||||
set_unlock_cookie(response, settings, management_slug, account, settings.dashboard_unlock_seconds)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/m/{management_slug}/password/remove", include_in_schema=False)
|
||||
async def remove_dashboard_password(
|
||||
management_slug: str,
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
) -> Response:
|
||||
account = await get_active_management_account_async(store, management_slug)
|
||||
if not has_management_access(request, management_slug, account):
|
||||
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||
form = await request_form(request, settings)
|
||||
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
|
||||
hostname = str(account["hostname"])
|
||||
if account.get("dashboard_password_hash"):
|
||||
is_current = await run_in_threadpool(
|
||||
store.verify_dashboard_password,
|
||||
hostname,
|
||||
first_form_value(form, "current_dashboard_password"),
|
||||
)
|
||||
if not is_current:
|
||||
return await management_error_response_async(service, settings, management_slug, account, "Current dashboard password is incorrect.")
|
||||
await run_in_threadpool(store.clear_dashboard_password, hostname)
|
||||
await run_in_threadpool(
|
||||
store.log_update_event,
|
||||
hostname,
|
||||
None,
|
||||
None,
|
||||
DASHBOARD_PASSWORD_REMOVED,
|
||||
"dashboard password removed",
|
||||
client_ip(request, settings),
|
||||
)
|
||||
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
|
||||
delete_unlock_cookie(response, management_slug)
|
||||
return response
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_models import DdnsSettings
|
||||
from ..ddns_security import (
|
||||
DASHBOARD_UNLOCK_COOKIE,
|
||||
dashboard_csrf_token,
|
||||
sign_dashboard_unlock_cookie,
|
||||
verify_dashboard_unlock_cookie,
|
||||
)
|
||||
from ..ddns_service import DdnsService
|
||||
from ..ddns_store import DdnsStore
|
||||
from ..store.types import AccountRow
|
||||
from ..web.templates_management import _render_management_page, _render_management_unlock_page
|
||||
|
||||
ManagementAccount = AccountRow
|
||||
|
||||
|
||||
def get_active_management_account(store: DdnsStore, management_slug: str) -> ManagementAccount:
|
||||
account = store.get_account_by_management_slug(management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return account
|
||||
|
||||
|
||||
async def get_active_management_account_async(store: DdnsStore, management_slug: str) -> ManagementAccount:
|
||||
return await run_in_threadpool(get_active_management_account, store, management_slug)
|
||||
|
||||
|
||||
def has_management_access(request: Request, management_slug: str, account: ManagementAccount) -> bool:
|
||||
password_hash = account.get("dashboard_password_hash")
|
||||
if not password_hash:
|
||||
return True
|
||||
return verify_dashboard_unlock_cookie(
|
||||
str(account["hostname"]),
|
||||
management_slug,
|
||||
str(password_hash),
|
||||
request.cookies.get(DASHBOARD_UNLOCK_COOKIE),
|
||||
)
|
||||
|
||||
|
||||
def locked_management_response(management_slug: str, account: ManagementAccount) -> HTMLResponse:
|
||||
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
||||
|
||||
|
||||
def management_error_response(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: ManagementAccount,
|
||||
message: str,
|
||||
) -> HTMLResponse:
|
||||
return HTMLResponse(
|
||||
_render_management_page(service, settings, management_slug, account, dashboard_csrf_token(management_slug, account), message=message),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
async def management_error_response_async(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: ManagementAccount,
|
||||
message: str,
|
||||
) -> HTMLResponse:
|
||||
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message)
|
||||
|
||||
|
||||
async def render_management_page_async(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: ManagementAccount,
|
||||
csrf_token: str,
|
||||
message: str | None = None,
|
||||
challenge: dict[str, str | None] | None = None,
|
||||
created_account: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
return await run_in_threadpool(
|
||||
_render_management_page,
|
||||
service,
|
||||
settings,
|
||||
management_slug,
|
||||
account,
|
||||
csrf_token,
|
||||
message,
|
||||
challenge,
|
||||
created_account,
|
||||
)
|
||||
|
||||
|
||||
def set_unlock_cookie(
|
||||
response: Response,
|
||||
settings: DdnsSettings,
|
||||
management_slug: str,
|
||||
account: ManagementAccount,
|
||||
max_age_seconds: int,
|
||||
) -> None:
|
||||
password_hash = account.get("dashboard_password_hash")
|
||||
if not password_hash:
|
||||
return
|
||||
response.set_cookie(
|
||||
DASHBOARD_UNLOCK_COOKIE,
|
||||
sign_dashboard_unlock_cookie(str(account["hostname"]), management_slug, str(password_hash), max_age_seconds),
|
||||
max_age=max_age_seconds,
|
||||
path=f"/m/{management_slug}",
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.public_base_url.startswith("https://"),
|
||||
)
|
||||
|
||||
|
||||
def delete_unlock_cookie(response: Response, management_slug: str) -> None:
|
||||
response.delete_cookie(DASHBOARD_UNLOCK_COOKIE, path=f"/m/{management_slug}", httponly=True, samesite="lax")
|
||||
|
||||
|
||||
def unlock_cookie_seconds(settings: DdnsSettings, stay_unlocked: str) -> int:
|
||||
if stay_unlocked:
|
||||
return settings.dashboard_stay_unlocked_seconds
|
||||
return settings.dashboard_unlock_seconds
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..ddns_security import client_ip
|
||||
from ..ddns_service import normalize_domain
|
||||
from ..dependencies import ServiceDep, SettingsDep
|
||||
from ..web.forms import first_form_value, request_form
|
||||
from ..web.templates_public import _render_privacy_page, _render_public_home
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
def index(settings: SettingsDep, service: ServiceDep) -> str:
|
||||
return _render_public_home(service, settings, None, None)
|
||||
|
||||
|
||||
@router.get("/magic", include_in_schema=False)
|
||||
def magic_form() -> RedirectResponse:
|
||||
return RedirectResponse("/#create", status_code=303)
|
||||
|
||||
|
||||
@router.post("/magic", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def magic_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
||||
await request_form(request, settings)
|
||||
account = await run_in_threadpool(service.create_managed_account, None)
|
||||
return _render_public_home(service, settings, account, None)
|
||||
|
||||
|
||||
@router.post("/request-domain", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def request_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
||||
form = await request_form(request, settings)
|
||||
try:
|
||||
challenge = await run_in_threadpool(
|
||||
service.create_domain_challenge,
|
||||
first_form_value(form, "domain"),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
||||
return _render_public_home(service, settings, None, None, challenge)
|
||||
|
||||
|
||||
@router.post("/verify-domain", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def verify_domain(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
||||
form = await request_form(request, settings)
|
||||
domain = normalize_domain(first_form_value(form, "domain"))
|
||||
claim_secret = first_form_value(form, "claim_secret")
|
||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
|
||||
if not found:
|
||||
message = "TXT record not found yet. DNS propagation can take a few minutes."
|
||||
return _render_public_home(service, settings, None, message, challenge)
|
||||
return _render_public_home(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge)
|
||||
|
||||
|
||||
@router.post("/accounts", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def self_service_account(request: Request, settings: SettingsDep, service: ServiceDep) -> str:
|
||||
form = await request_form(request, settings)
|
||||
mode = first_form_value(form, "mode")
|
||||
if mode == "managed":
|
||||
account = await run_in_threadpool(service.create_managed_account, None)
|
||||
else:
|
||||
claim_secret = first_form_value(form, "claim_secret")
|
||||
account = await run_in_threadpool(
|
||||
service.create_custom_account,
|
||||
first_form_value(form, "hostname"),
|
||||
claim_secret,
|
||||
None,
|
||||
)
|
||||
return _render_public_home(service, settings, account, None)
|
||||
|
||||
|
||||
@router.get("/healthz", response_class=PlainTextResponse)
|
||||
def healthz() -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
@router.get("/ip", response_class=PlainTextResponse)
|
||||
def visible_ip(request: Request, settings: SettingsDep) -> str:
|
||||
return client_ip(request, settings)
|
||||
|
||||
|
||||
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
|
||||
def privacy() -> str:
|
||||
return _render_privacy_page()
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from ..dependencies import ServiceDep, SettingsDep, StoreDep, apply_update_response, authenticate_update
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
|
||||
def slug_update(
|
||||
update_slug: str,
|
||||
request: Request,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
myip: str | None = Query(default=None),
|
||||
ipaddr: str | None = Query(default=None),
|
||||
myipv6: str | None = Query(default=None),
|
||||
ip6addr: str | None = Query(default=None),
|
||||
) -> str | PlainTextResponse:
|
||||
account = store.get_account_by_slug(update_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return apply_update_response(service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr)
|
||||
|
||||
|
||||
@router.get("/nic/update", response_class=PlainTextResponse, response_model=None)
|
||||
def update(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
store: StoreDep,
|
||||
service: ServiceDep,
|
||||
credentials: tuple[str | None, str | None] = Depends(authenticate_update),
|
||||
hostname: str | None = Query(default=None),
|
||||
domain: str | None = Query(default=None),
|
||||
myip: str | None = Query(default=None),
|
||||
ipaddr: str | None = Query(default=None),
|
||||
myipv6: str | None = Query(default=None),
|
||||
ip6addr: str | None = Query(default=None),
|
||||
) -> str | PlainTextResponse:
|
||||
host = (hostname or domain or "").strip().lower().rstrip(".")
|
||||
if not host:
|
||||
return PlainTextResponse("nohost", status_code=400)
|
||||
if settings.allowed_hosts and host not in settings.allowed_hosts:
|
||||
return PlainTextResponse("nohost", status_code=403)
|
||||
supplied_user, supplied_token = credentials
|
||||
account_ok = store.verify_account(host, supplied_user, supplied_token)
|
||||
global_secret_ok = (
|
||||
bool(settings.allowed_hosts)
|
||||
and host in settings.allowed_hosts
|
||||
and bool(settings.shared_secret)
|
||||
and bool(supplied_token)
|
||||
and secrets.compare_digest(supplied_token, settings.shared_secret)
|
||||
)
|
||||
if not global_secret_ok and not account_ok:
|
||||
return PlainTextResponse("badauth", status_code=401)
|
||||
return apply_update_response(service, request, host, myip, ipaddr, myipv6, ip6addr)
|
||||
Reference in new issue
Block a user