Archived
Simplify DDNS flow and polish UI
This commit is contained in:
1 parent
5beee0e5c6
commit
5a90bd9d87
42 files changed
+587
-1418
No files matched your search
@@ -39,14 +39,12 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
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)),
|
||||
store.get_service_metrics(),
|
||||
{
|
||||
"accounts": store.count_accounts(),
|
||||
"domain_challenges": store.count_domain_challenges(),
|
||||
"cleanup_runs": store.count_cleanup_runs(),
|
||||
"update_events": store.count_update_events(),
|
||||
},
|
||||
@@ -62,7 +60,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
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:
|
||||
@@ -72,7 +69,6 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
store,
|
||||
{
|
||||
"accounts": accounts_offset,
|
||||
"domain_challenges": domain_claims_offset,
|
||||
"cleanup_runs": cleanup_runs_offset,
|
||||
"update_events": update_events_offset,
|
||||
},
|
||||
@@ -184,14 +180,12 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
|
||||
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 done",
|
||||
[
|
||||
_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"])),
|
||||
],
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
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 ..ddns_service import BadIpError
|
||||
from ..dependencies import ServiceDep, StoreDep
|
||||
from ..schemas import (
|
||||
CustomHostnameRequest,
|
||||
DomainChallengeRequest,
|
||||
DomainChallengeResponse,
|
||||
ErrorResponse,
|
||||
HostnameCredentials,
|
||||
MagicHostnameRequest,
|
||||
ManagedRecord,
|
||||
RouterUpdateResponse,
|
||||
VerifyDomainRequest,
|
||||
VerifyDomainResponse,
|
||||
_credentials_response,
|
||||
_managed_record,
|
||||
)
|
||||
@@ -75,71 +68,6 @@ async def delete_managed_hostname(management_slug: ManagementSlugPath, store: St
|
||||
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="Create a hostname for a verified domain",
|
||||
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,
|
||||
|
||||
@@ -60,6 +60,10 @@ THEME_JS = """
|
||||
|
||||
CLIPBOARD_JS = """
|
||||
document.addEventListener("click", async (event) => {
|
||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||
if (!menu.contains(event.target)) menu.removeAttribute("open");
|
||||
});
|
||||
|
||||
const toggle = event.target.closest("[data-secret-toggle]");
|
||||
if (toggle) {
|
||||
const group = toggle.closest(".copy-field");
|
||||
@@ -84,13 +88,29 @@ document.addEventListener("click", async (event) => {
|
||||
if (!button) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(button.dataset.copy || "");
|
||||
const old = button.textContent;
|
||||
button.textContent = "Copied";
|
||||
const label = button.querySelector("[data-copy-label]");
|
||||
const oldLabel = label ? label.textContent : "";
|
||||
button.classList.add("is-copied");
|
||||
if (label) label.textContent = "Copied";
|
||||
const menu = button.closest("details.copy-menu");
|
||||
if (menu) {
|
||||
setTimeout(() => {
|
||||
menu.removeAttribute("open");
|
||||
}, 250);
|
||||
}
|
||||
setTimeout(() => {
|
||||
button.textContent = old;
|
||||
button.classList.remove("is-copied");
|
||||
if (label) label.textContent = oldLabel;
|
||||
}, 1200);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
document.querySelectorAll("details.copy-menu[open]").forEach((menu) => {
|
||||
menu.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
""".strip()
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ 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 . import management_passwords
|
||||
from .management_shared import (
|
||||
get_active_management_account,
|
||||
get_active_management_account_async,
|
||||
@@ -30,7 +30,6 @@ from .path_params import ManagementSlugPath
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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,
|
||||
)
|
||||
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: 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")
|
||||
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: 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, 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"))
|
||||
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="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: 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, 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 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"]),
|
||||
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: 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, 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"))
|
||||
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="Hostname created. Copy these router settings now.",
|
||||
created_account=created,
|
||||
)
|
||||
@@ -78,7 +78,6 @@ async def render_management_page_async(
|
||||
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(
|
||||
@@ -89,7 +88,6 @@ async def render_management_page_async(
|
||||
account,
|
||||
csrf_token,
|
||||
message,
|
||||
challenge,
|
||||
created_account,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,12 +26,3 @@ ManagementSlugPath = Annotated[
|
||||
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.",
|
||||
),
|
||||
]
|
||||
@@ -1,13 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
|
||||
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
|
||||
@@ -32,45 +29,15 @@ async def magic_account(request: Request, settings: SettingsDep, service: Servic
|
||||
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. 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 create a hostname now.", 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,
|
||||
)
|
||||
if mode and mode != "managed":
|
||||
raise HTTPException(status_code=400, detail="custom hostnames are not supported; create a KarlDNS hostname and use a CNAME")
|
||||
if first_form_value(form, "hostname") or first_form_value(form, "claim_secret"):
|
||||
raise HTTPException(status_code=400, detail="custom hostnames are not supported; create a KarlDNS hostname and use a CNAME")
|
||||
account = await run_in_threadpool(service.create_managed_account, None)
|
||||
return _render_public_home(service, settings, account, None)
|
||||
|
||||
|
||||
@@ -98,7 +65,6 @@ def robots_txt(settings: SettingsDep) -> str:
|
||||
"User-agent: *",
|
||||
f"Disallow: {admin_path}/",
|
||||
"Disallow: /m/",
|
||||
"Disallow: /d/",
|
||||
"Disallow: /u/",
|
||||
"Disallow: /nic/",
|
||||
"Disallow: /api/",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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
|
||||
from ..dependencies import ServiceDep, StoreDep, apply_update_response, authenticate_update
|
||||
from .path_params import UpdateSlugPath
|
||||
|
||||
router = APIRouter()
|
||||
@@ -25,13 +23,14 @@ def slug_update(
|
||||
account = store.get_account_by_slug(update_slug)
|
||||
if not account or account["disabled"]:
|
||||
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 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),
|
||||
@@ -45,17 +44,10 @@ def update(
|
||||
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:
|
||||
if not service.hostname_is_managed(host):
|
||||
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:
|
||||
if 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