Archived
Remove account-based DynDNS onboarding
This commit is contained in:
1 parent
86060d6bdc
commit
148918b283
8 files changed
+63
-595
No files matched your search
@@ -59,7 +59,7 @@ That means users always have a current hostname for VPN, remote access, self-hos
|
|||||||
| Custom domains | TXT challenge verification before hostname creation |
|
| Custom domains | TXT challenge verification before hostname creation |
|
||||||
| Persistence | SQLite with WAL mode |
|
| Persistence | SQLite with WAL mode |
|
||||||
| API | FastAPI, OpenAPI, Swagger UI, ReDoc |
|
| API | FastAPI, OpenAPI, Swagger UI, ReDoc |
|
||||||
| Auth model | Private magic links for hostnames and custom-domain claims; optional account workspace for dashboards |
|
| Auth model | No user accounts. Private bearer links for hostnames and custom-domain claims |
|
||||||
| Operations | Rate limiting, update logs, cleanup jobs, Docker, Caddy example |
|
| Operations | Rate limiting, update logs, cleanup jobs, Docker, Caddy example |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
+14
-268
@@ -19,10 +19,7 @@ from .ddns_security import (
|
|||||||
client_ip,
|
client_ip,
|
||||||
credentials_from_basic_auth,
|
credentials_from_basic_auth,
|
||||||
is_rate_limited_path,
|
is_rate_limited_path,
|
||||||
normalize_email,
|
|
||||||
require_admin_csrf,
|
require_admin_csrf,
|
||||||
set_session_cookie,
|
|
||||||
verify_session_cookie,
|
|
||||||
)
|
)
|
||||||
from .ddns_service import DdnsService, normalize_domain, normalize_hostname
|
from .ddns_service import DdnsService, normalize_domain, normalize_hostname
|
||||||
from .ddns_store import DdnsStore
|
from .ddns_store import DdnsStore
|
||||||
@@ -67,8 +64,6 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
settings = settings or DdnsSettings.from_env()
|
settings = settings or DdnsSettings.from_env()
|
||||||
if not settings.admin_password and settings.shared_secret:
|
if not settings.admin_password and settings.shared_secret:
|
||||||
settings = settings.model_copy(update={"admin_password": settings.shared_secret})
|
settings = settings.model_copy(update={"admin_password": settings.shared_secret})
|
||||||
if not settings.session_secret:
|
|
||||||
settings = settings.model_copy(update={"session_secret": settings.admin_password or settings.shared_secret})
|
|
||||||
if settings.require_dns_provider and not dns_backend_configured(settings):
|
if settings.require_dns_provider and not dns_backend_configured(settings):
|
||||||
raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
|
raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
|
||||||
|
|
||||||
@@ -139,10 +134,6 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
headers={"WWW-Authenticate": 'Basic realm="router_dyndns-ddns"'},
|
headers={"WWW-Authenticate": 'Basic realm="router_dyndns-ddns"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
def current_user(request: Request) -> dict[str, str | int] | None:
|
|
||||||
user_id = verify_session_cookie(request.cookies.get("ddns_session"), settings.session_secret)
|
|
||||||
return store.get_user(user_id) if user_id is not None else None
|
|
||||||
|
|
||||||
def apply_update(
|
def apply_update(
|
||||||
request: Request,
|
request: Request,
|
||||||
hostname: str,
|
hostname: str,
|
||||||
@@ -157,21 +148,14 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
return PlainTextResponse("911 dns publish failed", status_code=500)
|
return PlainTextResponse("911 dns publish failed", status_code=500)
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request) -> str:
|
def index() -> str:
|
||||||
user = current_user(request)
|
|
||||||
if user:
|
|
||||||
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, None)
|
|
||||||
return _render_public_home(service, settings, None, None)
|
return _render_public_home(service, settings, None, None)
|
||||||
|
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
|
||||||
def login_page() -> str:
|
|
||||||
return _render_auth_page(settings, None)
|
|
||||||
|
|
||||||
@app.post("/magic", response_class=HTMLResponse)
|
@app.post("/magic", response_class=HTMLResponse)
|
||||||
async def magic_account(request: Request) -> str:
|
async def magic_account(request: Request) -> str:
|
||||||
form = _parse_form(await request.body())
|
form = _parse_form(await request.body())
|
||||||
username = _first_form_value(form, "username") or None
|
username = _first_form_value(form, "username") or None
|
||||||
account = await run_in_threadpool(service.create_managed_account, username, None)
|
account = await run_in_threadpool(service.create_managed_account, username)
|
||||||
return _render_public_home(service, settings, account, None)
|
return _render_public_home(service, settings, account, None)
|
||||||
|
|
||||||
@app.get("/m/{management_slug}", response_class=HTMLResponse)
|
@app.get("/m/{management_slug}", response_class=HTMLResponse)
|
||||||
@@ -218,53 +202,34 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.post("/request-domain", response_class=HTMLResponse)
|
@app.post("/request-domain", response_class=HTMLResponse)
|
||||||
async def request_domain(request: Request) -> str:
|
async def request_domain(request: Request) -> str:
|
||||||
user = await run_in_threadpool(current_user, request)
|
|
||||||
form = _parse_form(await request.body())
|
form = _parse_form(await request.body())
|
||||||
try:
|
try:
|
||||||
challenge = await run_in_threadpool(
|
challenge = await run_in_threadpool(
|
||||||
service.create_domain_challenge,
|
service.create_domain_challenge,
|
||||||
_first_form_value(form, "domain"),
|
_first_form_value(form, "domain"),
|
||||||
int(user["id"]) if user else None,
|
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError as exc:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
||||||
if user:
|
|
||||||
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, challenge)
|
|
||||||
return _render_public_home(service, settings, None, None, challenge)
|
return _render_public_home(service, settings, None, None, challenge)
|
||||||
|
|
||||||
@app.post("/verify-domain", response_class=HTMLResponse)
|
@app.post("/verify-domain", response_class=HTMLResponse)
|
||||||
async def verify_domain(request: Request) -> str:
|
async def verify_domain(request: Request) -> str:
|
||||||
user = await run_in_threadpool(current_user, request)
|
|
||||||
form = _parse_form(await request.body())
|
form = _parse_form(await request.body())
|
||||||
domain = normalize_domain(_first_form_value(form, "domain"))
|
domain = normalize_domain(_first_form_value(form, "domain"))
|
||||||
claim_secret = _first_form_value(form, "claim_secret")
|
claim_secret = _first_form_value(form, "claim_secret")
|
||||||
owner_user_id = int(user["id"]) if user else None
|
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret)
|
||||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, owner_user_id)
|
|
||||||
if not found:
|
if not found:
|
||||||
message = "TXT record not found yet. DNS propagation can take a few minutes."
|
message = "TXT record not found yet. DNS propagation can take a few minutes."
|
||||||
if not user:
|
|
||||||
return _render_public_home(service, settings, None, message, challenge)
|
return _render_public_home(service, settings, None, message, challenge)
|
||||||
return _render_user_home(
|
|
||||||
service,
|
|
||||||
settings,
|
|
||||||
user,
|
|
||||||
store.list_accounts(int(user["id"])),
|
|
||||||
None,
|
|
||||||
message,
|
|
||||||
challenge,
|
|
||||||
)
|
|
||||||
if not user:
|
|
||||||
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 now create a hostname under it.", challenge)
|
||||||
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, "Domain verified. You can now create a hostname under it.", challenge)
|
|
||||||
|
|
||||||
@app.post("/accounts", response_class=HTMLResponse)
|
@app.post("/accounts", response_class=HTMLResponse)
|
||||||
async def self_service_account(request: Request) -> str:
|
async def self_service_account(request: Request) -> str:
|
||||||
user = await run_in_threadpool(current_user, request)
|
|
||||||
form = _parse_form(await request.body())
|
form = _parse_form(await request.body())
|
||||||
mode = _first_form_value(form, "mode")
|
mode = _first_form_value(form, "mode")
|
||||||
username = _first_form_value(form, "username") or None
|
username = _first_form_value(form, "username") or None
|
||||||
if mode == "managed":
|
if mode == "managed":
|
||||||
account = await run_in_threadpool(service.create_managed_account, username, int(user["id"]) if user else None)
|
account = await run_in_threadpool(service.create_managed_account, username)
|
||||||
else:
|
else:
|
||||||
claim_secret = _first_form_value(form, "claim_secret")
|
claim_secret = _first_form_value(form, "claim_secret")
|
||||||
account = await run_in_threadpool(
|
account = await run_in_threadpool(
|
||||||
@@ -272,50 +237,12 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
_first_form_value(form, "hostname"),
|
_first_form_value(form, "hostname"),
|
||||||
claim_secret,
|
claim_secret,
|
||||||
username,
|
username,
|
||||||
int(user["id"]) if user else None,
|
|
||||||
)
|
)
|
||||||
if not user:
|
|
||||||
return _render_public_home(service, settings, account, None)
|
return _render_public_home(service, settings, account, None)
|
||||||
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), account, None, None)
|
|
||||||
|
|
||||||
@app.post("/register")
|
|
||||||
async def register(request: Request) -> Response:
|
|
||||||
form = _parse_form(await request.body())
|
|
||||||
email = normalize_email(_first_form_value(form, "email"))
|
|
||||||
password = _first_form_value(form, "password")
|
|
||||||
invite = _first_form_value(form, "invite")
|
|
||||||
if not email or len(password) < 12:
|
|
||||||
return HTMLResponse(_render_auth_page(settings, "Use a valid email and a password with at least 12 characters."))
|
|
||||||
try:
|
|
||||||
user = await run_in_threadpool(store.create_user, email, password, invite, settings.require_invite)
|
|
||||||
except (sqlite3.IntegrityError, ValueError):
|
|
||||||
return HTMLResponse(
|
|
||||||
_render_auth_page(settings, "Registration failed. Check the invite code and email address."),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
response = RedirectResponse("/", status_code=303)
|
|
||||||
set_session_cookie(response, settings, int(user["id"]))
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.post("/login")
|
|
||||||
async def login(request: Request) -> Response:
|
|
||||||
form = _parse_form(await request.body())
|
|
||||||
user = await run_in_threadpool(store.authenticate_user, normalize_email(_first_form_value(form, "email")), _first_form_value(form, "password"))
|
|
||||||
if not user:
|
|
||||||
return HTMLResponse(_render_auth_page(settings, "Login failed."), status_code=401)
|
|
||||||
response = RedirectResponse("/", status_code=303)
|
|
||||||
set_session_cookie(response, settings, int(user["id"]))
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.post("/logout")
|
|
||||||
def logout() -> Response:
|
|
||||||
response = RedirectResponse("/", status_code=303)
|
|
||||||
response.delete_cookie("ddns_session")
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.get("/admin", response_class=HTMLResponse)
|
@app.get("/admin", response_class=HTMLResponse)
|
||||||
def admin(_: None = Depends(authenticate_admin)) -> str:
|
def admin(_: None = Depends(authenticate_admin)) -> str:
|
||||||
return _render_admin_page(service, settings, store.list_accounts(), store.list_invites(), None)
|
return _render_admin_page(service, settings, store.list_accounts(), None)
|
||||||
|
|
||||||
@app.post("/admin/accounts")
|
@app.post("/admin/accounts")
|
||||||
async def admin_create_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
|
async def admin_create_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
|
||||||
@@ -331,7 +258,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
account = store.create_account(hostname, username)
|
account = store.create_account(hostname, username)
|
||||||
except sqlite3.IntegrityError as exc:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise HTTPException(status_code=409, detail="hostname already exists") from exc
|
raise HTTPException(status_code=409, detail="hostname already exists") from exc
|
||||||
return HTMLResponse(_render_admin_page(service, settings, store.list_accounts(), store.list_invites(), account))
|
return HTMLResponse(_render_admin_page(service, settings, store.list_accounts(), account))
|
||||||
|
|
||||||
@app.post("/admin/accounts/delete")
|
@app.post("/admin/accounts/delete")
|
||||||
async def admin_delete_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
|
async def admin_delete_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
|
||||||
@@ -345,13 +272,6 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
|
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
|
||||||
return RedirectResponse("/admin", status_code=303)
|
return RedirectResponse("/admin", status_code=303)
|
||||||
|
|
||||||
@app.post("/admin/invites")
|
|
||||||
async def create_invite(request: Request, _: None = Depends(authenticate_admin)) -> Response:
|
|
||||||
form = _parse_form(await request.body())
|
|
||||||
require_admin_csrf(settings, _first_form_value(form, "csrf"))
|
|
||||||
store.create_invite()
|
|
||||||
return RedirectResponse("/admin", status_code=303)
|
|
||||||
|
|
||||||
@app.get("/records")
|
@app.get("/records")
|
||||||
def records(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]:
|
def records(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]:
|
||||||
return store.list_records()
|
return store.list_records()
|
||||||
@@ -423,16 +343,6 @@ def _first_form_value(form: dict[str, list[str]], key: str) -> str:
|
|||||||
return form.get(key, [""])[0].strip()
|
return form.get(key, [""])[0].strip()
|
||||||
|
|
||||||
|
|
||||||
def _render_magic_page(
|
|
||||||
service: DdnsService,
|
|
||||||
settings: DdnsSettings,
|
|
||||||
created_account: dict[str, str] | None,
|
|
||||||
message: str | None,
|
|
||||||
challenge: dict[str, str | None] | None = None,
|
|
||||||
) -> str:
|
|
||||||
return _render_public_home(service, settings, created_account, message, challenge)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_public_home(
|
def _render_public_home(
|
||||||
service: DdnsService,
|
service: DdnsService,
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
@@ -490,138 +400,11 @@ def _render_public_home(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_user_home(
|
def _top_nav(label: str | None = None, links: str = "") -> str:
|
||||||
service: DdnsService,
|
|
||||||
settings: DdnsSettings,
|
|
||||||
user: dict[str, str | int],
|
|
||||||
accounts: list[dict[str, str | int | None]],
|
|
||||||
created_account: dict[str, str] | None,
|
|
||||||
message: str | None,
|
|
||||||
challenge: dict[str, str | None] | None = None,
|
|
||||||
) -> str:
|
|
||||||
created_html = _credentials_panel(service, created_account)
|
|
||||||
message_html = _message_band(message) if message and not challenge else ""
|
|
||||||
account_rows = "\n".join(_account_row(account, show_owner=False) for account in accounts) or """
|
|
||||||
<tr><td colspan="6" class="empty">No hostnames yet.</td></tr>
|
|
||||||
"""
|
|
||||||
return _page(
|
|
||||||
"router-dyndns dashboard",
|
|
||||||
f"""
|
|
||||||
<main>
|
|
||||||
{_top_nav(str(user["email"]), True)}
|
|
||||||
<section class="section">
|
|
||||||
<div class="container page-heading">
|
|
||||||
<p class="eyebrow">Dashboard</p>
|
|
||||||
<h1>Your router hostnames</h1>
|
|
||||||
<p class="lead">Create provider hostnames immediately, or add a domain and verify it with a DNS TXT record.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{message_html}
|
|
||||||
{created_html}
|
|
||||||
<section class="section">
|
|
||||||
<div class="container tool-grid">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Provider hostname</p>
|
|
||||||
<h2>Create a random hostname</h2>
|
|
||||||
<p class="section-copy">Best for simple FRITZ!Box setups. The hostname and update link are generated for you.</p>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/accounts" class="tool-card">
|
|
||||||
<input type="hidden" name="mode" value="managed">
|
|
||||||
<label>Username
|
|
||||||
<input name="username" placeholder="optional">
|
|
||||||
</label>
|
|
||||||
<button type="submit">Create hostname</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{_custom_domain_flow(service, challenge, message)}
|
|
||||||
<section class="section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="section-heading">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Inventory</p>
|
|
||||||
<h2>Hostnames</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th></th></tr></thead>
|
|
||||||
<tbody>{account_rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_auth_page(settings: DdnsSettings, message: str | None) -> str:
|
|
||||||
invite_field = """
|
|
||||||
<label>Invite code
|
|
||||||
<input name="invite" autocomplete="off" required>
|
|
||||||
</label>
|
|
||||||
""" if settings.require_invite else ""
|
|
||||||
return _page(
|
|
||||||
"Sign in",
|
|
||||||
f"""
|
|
||||||
<main>
|
|
||||||
{_top_nav()}
|
|
||||||
<section class="hero-band compact">
|
|
||||||
<div class="container hero-inner">
|
|
||||||
<p class="eyebrow">Custom domains</p>
|
|
||||||
<h1>Optional account workspace.</h1>
|
|
||||||
<p class="lead">Most users do not need this. Magic links handle normal hostname and domain setup; accounts are only for people who want a persistent dashboard.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{_message_band(message) if message else ""}
|
|
||||||
<section class="section">
|
|
||||||
<div class="container auth-grid">
|
|
||||||
<div class="tool-card">
|
|
||||||
<h2>Login</h2>
|
|
||||||
<form method="post" action="/login" class="stack-form">
|
|
||||||
<label>Email<input name="email" type="email" autocomplete="email" required></label>
|
|
||||||
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
|
|
||||||
<button type="submit">Login</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="tool-card secondary-card">
|
|
||||||
<h2>Register</h2>
|
|
||||||
<form method="post" action="/register" class="stack-form">
|
|
||||||
<label>Email<input name="email" type="email" autocomplete="email" required></label>
|
|
||||||
<label>Password<input name="password" type="password" autocomplete="new-password" minlength="12" required></label>
|
|
||||||
{invite_field}
|
|
||||||
<button type="submit">Create account</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_public_page(
|
|
||||||
service: DdnsService,
|
|
||||||
settings: DdnsSettings,
|
|
||||||
user: dict[str, str | int] | None,
|
|
||||||
accounts: list[dict[str, str | int | None]],
|
|
||||||
challenge: dict[str, str | None] | None,
|
|
||||||
message: str | None,
|
|
||||||
created_account: dict[str, str] | None = None,
|
|
||||||
) -> str:
|
|
||||||
if user:
|
|
||||||
return _render_user_home(service, settings, user, accounts, created_account, message, challenge)
|
|
||||||
return _render_public_home(service, settings, created_account, message, challenge)
|
|
||||||
|
|
||||||
|
|
||||||
def _top_nav(label: str | None = None, show_logout: bool = False, links: str = "") -> str:
|
|
||||||
account_html = ""
|
account_html = ""
|
||||||
if label:
|
if label:
|
||||||
account_html = f'<span class="nav-label">{html.escape(label)}</span>'
|
account_html = f'<span class="nav-label">{html.escape(label)}</span>'
|
||||||
if show_logout:
|
if not links:
|
||||||
account_html += '<form method="post" action="/logout"><button class="nav-button" type="submit">Logout</button></form>'
|
|
||||||
elif not links:
|
|
||||||
account_html += '<a class="nav-button" href="/#custom-domain">Domain setup</a>'
|
account_html += '<a class="nav-button" href="/#custom-domain">Domain setup</a>'
|
||||||
return f"""
|
return f"""
|
||||||
<nav class="top-nav">
|
<nav class="top-nav">
|
||||||
@@ -717,10 +500,6 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _challenge_panel(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> str:
|
|
||||||
return _custom_domain_flow(service, challenge, message)
|
|
||||||
|
|
||||||
|
|
||||||
def _credentials_panel(service: DdnsService, created_account: dict[str, str] | None) -> str:
|
def _credentials_panel(service: DdnsService, created_account: dict[str, str] | None) -> str:
|
||||||
if not created_account:
|
if not created_account:
|
||||||
return ""
|
return ""
|
||||||
@@ -851,15 +630,11 @@ def _render_admin_page(
|
|||||||
service: DdnsService,
|
service: DdnsService,
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
accounts: list[dict[str, str | int | None]],
|
accounts: list[dict[str, str | int | None]],
|
||||||
invites: list[dict[str, str | None]],
|
|
||||||
created_account: dict[str, str] | None,
|
created_account: dict[str, str] | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
csrf = html.escape(admin_csrf_token(settings))
|
csrf = html.escape(admin_csrf_token(settings))
|
||||||
rows = "\n".join(_account_row(account, show_owner=True, csrf=csrf) for account in accounts) or """
|
rows = "\n".join(_account_row(account, csrf=csrf) for account in accounts) or """
|
||||||
<tr><td colspan="7" class="empty">No accounts yet.</td></tr>
|
<tr><td colspan="6" class="empty">No accounts yet.</td></tr>
|
||||||
"""
|
|
||||||
invite_rows = "\n".join(_invite_row(invite) for invite in invites) or """
|
|
||||||
<tr><td colspan="3" class="empty">No invites yet.</td></tr>
|
|
||||||
"""
|
"""
|
||||||
suffix_help = (
|
suffix_help = (
|
||||||
f"Short names are expanded under {html.escape(settings.hostname_suffix)}."
|
f"Short names are expanded under {html.escape(settings.hostname_suffix)}."
|
||||||
@@ -870,7 +645,7 @@ def _render_admin_page(
|
|||||||
"DynDNS Admin",
|
"DynDNS Admin",
|
||||||
f"""
|
f"""
|
||||||
<main>
|
<main>
|
||||||
{_top_nav("Operator console", False, '<a href="/records">Records</a><a href="/events">Events</a>')}
|
{_top_nav("Operator console", '<a href="/records">Records</a><a href="/events">Events</a>')}
|
||||||
<section class="section admin-heading">
|
<section class="section admin-heading">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="eyebrow">DynDNS aktiv</p>
|
<p class="eyebrow">DynDNS aktiv</p>
|
||||||
@@ -902,27 +677,6 @@ def _render_admin_page(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="container">
|
|
||||||
<div class="section-heading">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Invites</p>
|
|
||||||
<h2>Registration codes</h2>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="/admin/invites">
|
|
||||||
<input type="hidden" name="csrf" value="{csrf}">
|
|
||||||
<button type="submit">Create invite</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Code</th><th>Created</th><th>Used</th></tr></thead>
|
|
||||||
<tbody>{invite_rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
@@ -934,7 +688,7 @@ def _render_admin_page(
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Domain</th><th>User</th><th>Owner</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th></th></tr>
|
<tr><th>Domain</th><th>User</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{rows}</tbody>
|
<tbody>{rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -946,16 +700,15 @@ def _render_admin_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _account_row(account: dict[str, str | int | None], show_owner: bool, csrf: str = "") -> str:
|
def _account_row(account: dict[str, str | int | None], csrf: str = "") -> str:
|
||||||
hostname = html.escape(str(account["hostname"]))
|
hostname = html.escape(str(account["hostname"]))
|
||||||
username = html.escape(str(account["username"]))
|
username = html.escape(str(account["username"]))
|
||||||
ipv4 = html.escape(str(account.get("ipv4") or "-"))
|
ipv4 = html.escape(str(account.get("ipv4") or "-"))
|
||||||
ipv6 = html.escape(str(account.get("ipv6") or "-"))
|
ipv6 = html.escape(str(account.get("ipv6") or "-"))
|
||||||
updated = html.escape(str(account.get("updated_at") or "Never"))
|
updated = html.escape(str(account.get("updated_at") or "Never"))
|
||||||
owner = f"<td>{html.escape(str(account.get('owner_user_id') or '-'))}</td>" if show_owner else ""
|
|
||||||
return f"""
|
return f"""
|
||||||
<tr>
|
<tr>
|
||||||
<td>{hostname}</td><td>{username}</td>{owner}<td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
|
<td>{hostname}</td><td>{username}</td><td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/admin/accounts/delete">
|
<form method="post" action="/admin/accounts/delete">
|
||||||
<input type="hidden" name="csrf" value="{html.escape(csrf)}">
|
<input type="hidden" name="csrf" value="{html.escape(csrf)}">
|
||||||
@@ -967,13 +720,6 @@ def _account_row(account: dict[str, str | int | None], show_owner: bool, csrf: s
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _invite_row(invite: dict[str, str | None]) -> str:
|
|
||||||
code = html.escape(str(invite["code"]))
|
|
||||||
created = html.escape(str(invite.get("created_at") or "-"))
|
|
||||||
used = html.escape(str(invite.get("used_at") or "-"))
|
|
||||||
return f"<tr><td><input readonly value=\"{code}\"></td><td>{created}</td><td>{used}</td></tr>"
|
|
||||||
|
|
||||||
|
|
||||||
def _page(title: str, body: str) -> str:
|
def _page(title: str, body: str) -> str:
|
||||||
return f"""
|
return f"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from .ddns_models import DdnsSettings
|
from .ddns_models import DdnsSettings
|
||||||
from .ddns_security import verify_session_cookie
|
|
||||||
from .ddns_service import DdnsService, normalize_domain
|
from .ddns_service import DdnsService, normalize_domain
|
||||||
from .ddns_store import DdnsStore
|
from .ddns_store import DdnsStore
|
||||||
|
|
||||||
@@ -73,12 +72,6 @@ class RouterUpdateResponse(BaseModel):
|
|||||||
def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsService) -> APIRouter:
|
def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsService) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
def optional_user(request: Request) -> dict[str, str | int] | None:
|
|
||||||
user_id = verify_session_cookie(request.cookies.get("ddns_session"), settings.session_secret)
|
|
||||||
return store.get_user(user_id) if user_id is not None else None
|
|
||||||
|
|
||||||
optional_user_dependency = Depends(optional_user)
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/hostnames/magic",
|
"/hostnames/magic",
|
||||||
response_model=HostnameCredentials,
|
response_model=HostnameCredentials,
|
||||||
@@ -88,7 +81,7 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
|
|||||||
responses={500: {"model": ErrorResponse}},
|
responses={500: {"model": ErrorResponse}},
|
||||||
)
|
)
|
||||||
async def create_magic_hostname(payload: MagicHostnameRequest) -> HostnameCredentials:
|
async def create_magic_hostname(payload: MagicHostnameRequest) -> HostnameCredentials:
|
||||||
account = await run_in_threadpool(service.create_managed_account, payload.username, None)
|
account = await run_in_threadpool(service.create_managed_account, payload.username)
|
||||||
return _credentials_response(service, account)
|
return _credentials_response(service, account)
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -132,11 +125,10 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
|
|||||||
)
|
)
|
||||||
async def create_domain_challenge(
|
async def create_domain_challenge(
|
||||||
payload: DomainChallengeRequest,
|
payload: DomainChallengeRequest,
|
||||||
user: dict[str, str | int] | None = optional_user_dependency,
|
|
||||||
) -> DomainChallengeResponse:
|
) -> DomainChallengeResponse:
|
||||||
domain = normalize_domain(payload.domain)
|
domain = normalize_domain(payload.domain)
|
||||||
try:
|
try:
|
||||||
challenge = await run_in_threadpool(service.create_domain_challenge, domain, int(user["id"]) if user else None)
|
challenge = await run_in_threadpool(service.create_domain_challenge, domain)
|
||||||
except sqlite3.IntegrityError as exc:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
||||||
return DomainChallengeResponse(
|
return DomainChallengeResponse(
|
||||||
@@ -155,10 +147,9 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
|
|||||||
)
|
)
|
||||||
async def verify_domain(
|
async def verify_domain(
|
||||||
payload: VerifyDomainRequest,
|
payload: VerifyDomainRequest,
|
||||||
user: dict[str, str | int] | None = optional_user_dependency,
|
|
||||||
) -> VerifyDomainResponse:
|
) -> VerifyDomainResponse:
|
||||||
domain = normalize_domain(payload.domain)
|
domain = normalize_domain(payload.domain)
|
||||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret, int(user["id"]) if user else None)
|
found, challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret)
|
||||||
if not found:
|
if not found:
|
||||||
return VerifyDomainResponse(
|
return VerifyDomainResponse(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
@@ -183,14 +174,12 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
|
|||||||
)
|
)
|
||||||
async def create_custom_hostname(
|
async def create_custom_hostname(
|
||||||
payload: CustomHostnameRequest,
|
payload: CustomHostnameRequest,
|
||||||
user: dict[str, str | int] | None = optional_user_dependency,
|
|
||||||
) -> HostnameCredentials:
|
) -> HostnameCredentials:
|
||||||
account = await run_in_threadpool(
|
account = await run_in_threadpool(
|
||||||
service.create_custom_account,
|
service.create_custom_account,
|
||||||
payload.hostname,
|
payload.hostname,
|
||||||
payload.claim_secret,
|
payload.claim_secret,
|
||||||
payload.username,
|
payload.username,
|
||||||
int(user["id"]) if user else None,
|
|
||||||
)
|
)
|
||||||
return _credentials_response(service, account)
|
return _credentials_response(service, account)
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ class DdnsSettings(BaseModel):
|
|||||||
public_base_url: str = Field(default="http://localhost:8080")
|
public_base_url: str = Field(default="http://localhost:8080")
|
||||||
hostname_suffix: str = Field(default="")
|
hostname_suffix: str = Field(default="")
|
||||||
verification_prefix: str = Field(default="_router_dyndns-ddns")
|
verification_prefix: str = Field(default="_router_dyndns-ddns")
|
||||||
session_secret: str = Field(default="")
|
|
||||||
require_invite: bool = Field(default=False)
|
|
||||||
max_hostnames_per_user: int = Field(default=5, ge=1, le=100)
|
|
||||||
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
||||||
trusted_proxy_ips: set[str] = Field(default_factory=set)
|
trusted_proxy_ips: set[str] = Field(default_factory=set)
|
||||||
rfc2136_server: str | None = None
|
rfc2136_server: str | None = None
|
||||||
@@ -56,9 +53,6 @@ class DdnsSettings(BaseModel):
|
|||||||
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
|
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||||
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
|
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
|
||||||
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"),
|
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"),
|
||||||
session_secret=os.getenv("DDNS_SESSION_SECRET", os.getenv("DDNS_ADMIN_PASSWORD", "")),
|
|
||||||
require_invite=os.getenv("DDNS_REQUIRE_INVITE", "0").strip().lower() not in {"0", "false", "no"},
|
|
||||||
max_hostnames_per_user=_env_int("DDNS_MAX_HOSTNAMES_PER_USER", 5),
|
|
||||||
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
|
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
|
||||||
trusted_proxy_ips=trusted_proxy_ips,
|
trusted_proxy_ips=trusted_proxy_ips,
|
||||||
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
|
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import re
|
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from ipaddress import ip_address, ip_network
|
from ipaddress import ip_address, ip_network
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, Response
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from .ddns_models import DdnsSettings
|
from .ddns_models import DdnsSettings
|
||||||
|
|
||||||
@@ -46,49 +44,8 @@ def hash_lookup_token(token: str) -> str:
|
|||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def normalize_email(value: str) -> str:
|
|
||||||
email = value.strip().lower()
|
|
||||||
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
|
|
||||||
return ""
|
|
||||||
return email
|
|
||||||
|
|
||||||
|
|
||||||
def make_session_cookie(user_id: int, secret: str) -> str:
|
|
||||||
timestamp = str(int(time.time()))
|
|
||||||
payload = f"{user_id}:{timestamp}"
|
|
||||||
signature = _session_signature(payload, secret)
|
|
||||||
return base64.urlsafe_b64encode(f"{payload}:{signature}".encode()).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_session_cookie(value: str | None, secret: str, max_age_seconds: int = 60 * 60 * 24 * 30) -> int | None:
|
|
||||||
if not value or not secret:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
decoded = base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
|
|
||||||
user_id, timestamp, signature = decoded.split(":", 2)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
payload = f"{user_id}:{timestamp}"
|
|
||||||
if not hmac.compare_digest(signature, _session_signature(payload, secret)):
|
|
||||||
return None
|
|
||||||
if int(time.time()) - int(timestamp) > max_age_seconds:
|
|
||||||
return None
|
|
||||||
return int(user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def set_session_cookie(response: Response, settings: DdnsSettings, user_id: int) -> None:
|
|
||||||
response.set_cookie(
|
|
||||||
"ddns_session",
|
|
||||||
make_session_cookie(user_id, settings.session_secret),
|
|
||||||
max_age=60 * 60 * 24 * 30,
|
|
||||||
httponly=True,
|
|
||||||
secure=settings.public_base_url.startswith("https://"),
|
|
||||||
samesite="strict",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def admin_csrf_token(settings: DdnsSettings) -> str:
|
def admin_csrf_token(settings: DdnsSettings) -> str:
|
||||||
secret = settings.admin_password or settings.shared_secret or settings.session_secret
|
secret = settings.admin_password or settings.shared_secret
|
||||||
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||||
return hmac.new(secret.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest()
|
return hmac.new(secret.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
@@ -109,8 +66,6 @@ def client_ip(request: Request, settings: DdnsSettings | None = None) -> str:
|
|||||||
def is_rate_limited_path(path: str) -> bool:
|
def is_rate_limited_path(path: str) -> bool:
|
||||||
limited_paths = {
|
limited_paths = {
|
||||||
"/magic",
|
"/magic",
|
||||||
"/login",
|
|
||||||
"/register",
|
|
||||||
"/accounts",
|
"/accounts",
|
||||||
"/request-domain",
|
"/request-domain",
|
||||||
"/verify-domain",
|
"/verify-domain",
|
||||||
@@ -121,10 +76,6 @@ def is_rate_limited_path(path: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _session_signature(payload: str, secret: str) -> str:
|
|
||||||
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
|
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
|
||||||
if not trusted:
|
if not trusted:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -29,18 +29,12 @@ class DdnsService:
|
|||||||
self._locks_guard = threading.Lock()
|
self._locks_guard = threading.Lock()
|
||||||
self._locks: dict[str, threading.Lock] = {}
|
self._locks: dict[str, threading.Lock] = {}
|
||||||
|
|
||||||
def create_managed_account(self, username: str | None = None, owner_user_id: int | None = None) -> dict[str, str]:
|
def create_managed_account(self, username: str | None = None) -> dict[str, str]:
|
||||||
if not self.settings.hostname_suffix:
|
if not self.settings.hostname_suffix:
|
||||||
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
|
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
|
||||||
if owner_user_id is not None:
|
|
||||||
self._enforce_account_quota(owner_user_id)
|
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
try:
|
try:
|
||||||
return self.store.create_account(
|
return self.store.create_account(self.random_managed_hostname(), username)
|
||||||
self.random_managed_hostname(),
|
|
||||||
username,
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
)
|
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
continue
|
continue
|
||||||
raise HTTPException(status_code=500, detail="could not allocate hostname")
|
raise HTTPException(status_code=500, detail="could not allocate hostname")
|
||||||
@@ -50,36 +44,33 @@ class DdnsService:
|
|||||||
hostname: str,
|
hostname: str,
|
||||||
claim_secret: str,
|
claim_secret: str,
|
||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
owner_user_id: int | None = None,
|
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
normalized = normalize_hostname(hostname, self.settings, expand_suffix=False)
|
normalized = normalize_hostname(hostname, self.settings, expand_suffix=False)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise HTTPException(status_code=400, detail="valid custom hostname is required")
|
raise HTTPException(status_code=400, detail="valid custom hostname is required")
|
||||||
if not self.hostname_is_publishable(normalized):
|
if not self.hostname_is_publishable(normalized):
|
||||||
raise HTTPException(status_code=403, detail="hostname is outside configured DNS publishing zones")
|
raise HTTPException(status_code=403, detail="hostname is outside configured DNS publishing zones")
|
||||||
if not self.best_verified_parent(normalized, owner_user_id, claim_secret):
|
if not self.best_verified_parent(normalized, claim_secret):
|
||||||
raise HTTPException(status_code=403, detail="verify the parent domain before creating this hostname")
|
raise HTTPException(status_code=403, detail="verify the parent domain before creating this hostname")
|
||||||
if owner_user_id is not None:
|
|
||||||
self._enforce_account_quota(owner_user_id)
|
|
||||||
try:
|
try:
|
||||||
return self.store.create_account(normalized, username, owner_user_id=owner_user_id)
|
return self.store.create_account(normalized, username)
|
||||||
except sqlite3.IntegrityError as exc:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise HTTPException(status_code=409, detail="hostname already exists") from exc
|
raise HTTPException(status_code=409, detail="hostname already exists") from exc
|
||||||
|
|
||||||
def create_domain_challenge(self, domain: str, owner_user_id: int | None = None) -> dict[str, str]:
|
def create_domain_challenge(self, domain: str) -> dict[str, str]:
|
||||||
normalized = normalize_domain(domain)
|
normalized = normalize_domain(domain)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise HTTPException(status_code=400, detail="valid domain is required")
|
raise HTTPException(status_code=400, detail="valid domain is required")
|
||||||
existing = self.store.get_domain_challenge(normalized, owner_user_id=owner_user_id)
|
existing = self.store.get_domain_challenge(normalized)
|
||||||
if existing and existing.get("verified_at"):
|
if existing and existing.get("verified_at"):
|
||||||
raise HTTPException(status_code=409, detail="domain is already verified")
|
raise HTTPException(status_code=409, detail="domain is already verified")
|
||||||
return self.store.create_domain_challenge(normalized, owner_user_id)
|
return self.store.create_domain_challenge(normalized)
|
||||||
|
|
||||||
def verify_domain(self, domain: str, claim_secret: str, owner_user_id: int | None = None) -> tuple[bool, dict[str, str | None]]:
|
def verify_domain(self, domain: str, claim_secret: str) -> tuple[bool, dict[str, str | None]]:
|
||||||
normalized = normalize_domain(domain)
|
normalized = normalize_domain(domain)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise HTTPException(status_code=400, detail="valid domain is required")
|
raise HTTPException(status_code=400, detail="valid domain is required")
|
||||||
challenge = self.store.get_domain_challenge(normalized, owner_user_id=owner_user_id, claim_secret=claim_secret)
|
challenge = self.store.get_domain_challenge(normalized, claim_secret=claim_secret)
|
||||||
if not challenge:
|
if not challenge:
|
||||||
raise HTTPException(status_code=404, detail="domain challenge not found")
|
raise HTTPException(status_code=404, detail="domain challenge not found")
|
||||||
found = dns_txt_contains(self.verification_name(normalized), str(challenge["token"]))
|
found = dns_txt_contains(self.verification_name(normalized), str(challenge["token"]))
|
||||||
@@ -87,7 +78,7 @@ class DdnsService:
|
|||||||
challenge = dict(challenge)
|
challenge = dict(challenge)
|
||||||
challenge["claim_secret"] = claim_secret
|
challenge["claim_secret"] = claim_secret
|
||||||
return False, challenge
|
return False, challenge
|
||||||
verified = self.store.verify_domain_challenge(normalized, owner_user_id=owner_user_id)
|
verified = self.store.verify_domain_challenge(normalized)
|
||||||
if verified is None:
|
if verified is None:
|
||||||
raise HTTPException(status_code=404, detail="domain challenge not found")
|
raise HTTPException(status_code=404, detail="domain challenge not found")
|
||||||
verified = dict(verified)
|
verified = dict(verified)
|
||||||
@@ -147,18 +138,13 @@ class DdnsService:
|
|||||||
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
|
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
|
||||||
return f"{secrets.token_hex(6)}.{suffix}"
|
return f"{secrets.token_hex(6)}.{suffix}"
|
||||||
|
|
||||||
def best_verified_parent(
|
def best_verified_parent(self, hostname: str, claim_secret: str | None = None) -> str | None:
|
||||||
self,
|
|
||||||
hostname: str,
|
|
||||||
owner_user_id: int | None = None,
|
|
||||||
claim_secret: str | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
labels = hostname.split(".")
|
labels = hostname.split(".")
|
||||||
for index in range(1, len(labels) - 1):
|
for index in range(1, len(labels) - 1):
|
||||||
candidate = ".".join(labels[index:])
|
candidate = ".".join(labels[index:])
|
||||||
if self.store.is_domain_verified(candidate, owner_user_id, claim_secret):
|
if self.store.is_domain_verified(candidate, claim_secret):
|
||||||
return candidate
|
return candidate
|
||||||
if self.store.is_domain_verified(hostname, owner_user_id, claim_secret):
|
if self.store.is_domain_verified(hostname, claim_secret):
|
||||||
return hostname
|
return hostname
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -180,10 +166,6 @@ class DdnsService:
|
|||||||
zones.add(self.settings.hostname_suffix.strip().lower().strip("."))
|
zones.add(self.settings.hostname_suffix.strip().lower().strip("."))
|
||||||
return bool(zones) and any(hostname == zone or hostname.endswith(f".{zone}") for zone in zones)
|
return bool(zones) and any(hostname == zone or hostname.endswith(f".{zone}") for zone in zones)
|
||||||
|
|
||||||
def _enforce_account_quota(self, owner_user_id: int) -> None:
|
|
||||||
if self.store.count_user_accounts(owner_user_id) >= self.settings.max_hostnames_per_user:
|
|
||||||
raise HTTPException(status_code=403, detail="hostname quota exceeded")
|
|
||||||
|
|
||||||
def _lock_for(self, hostname: str) -> threading.Lock:
|
def _lock_for(self, hostname: str) -> threading.Lock:
|
||||||
with self._locks_guard:
|
with self._locks_guard:
|
||||||
lock = self._locks.get(hostname)
|
lock = self._locks.get(hostname)
|
||||||
|
|||||||
+27
-152
@@ -84,12 +84,7 @@ class DdnsStore:
|
|||||||
update_ipv6=update_ipv6,
|
update_ipv6=update_ipv6,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_account(
|
def create_account(self, hostname: str, username: str | None = None) -> dict[str, str]:
|
||||||
self,
|
|
||||||
hostname: str,
|
|
||||||
username: str | None = None,
|
|
||||||
owner_user_id: int | None = None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
update_slug = secrets.token_urlsafe(32)
|
update_slug = secrets.token_urlsafe(32)
|
||||||
@@ -100,10 +95,10 @@ class DdnsStore:
|
|||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, owner_user_id, created_at, disabled)
|
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||||
""",
|
""",
|
||||||
(hostname, username, token_hash, update_slug, management_hash, owner_user_id, now),
|
(hostname, username, token_hash, update_slug, management_hash, now),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
@@ -113,21 +108,18 @@ class DdnsStore:
|
|||||||
"management_slug": management_slug,
|
"management_slug": management_slug,
|
||||||
}
|
}
|
||||||
|
|
||||||
def list_accounts(self, owner_user_id: int | None = None) -> list[dict[str, str | int | None]]:
|
def list_accounts(self) -> list[dict[str, str | int | None]]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
sql = """
|
rows = conn.execute(
|
||||||
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.owner_user_id,
|
"""
|
||||||
|
SELECT accounts.hostname, accounts.username, accounts.update_slug,
|
||||||
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||||
FROM accounts
|
FROM accounts
|
||||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||||
|
ORDER BY accounts.hostname
|
||||||
"""
|
"""
|
||||||
params: tuple[int, ...] = ()
|
).fetchall()
|
||||||
if owner_user_id is not None:
|
|
||||||
sql += " WHERE accounts.owner_user_id = ?"
|
|
||||||
params = (owner_user_id,)
|
|
||||||
sql += " ORDER BY accounts.hostname"
|
|
||||||
rows = conn.execute(sql, params).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def get_account_by_slug(self, update_slug: str) -> dict[str, str | int | None] | None:
|
def get_account_by_slug(self, update_slug: str) -> dict[str, str | int | None] | None:
|
||||||
@@ -147,7 +139,7 @@ class DdnsStore:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
account = conn.execute(
|
account = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.owner_user_id,
|
SELECT accounts.hostname, accounts.username, accounts.update_slug,
|
||||||
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||||
FROM accounts
|
FROM accounts
|
||||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||||
@@ -178,66 +170,47 @@ class DdnsStore:
|
|||||||
conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,))
|
conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,))
|
||||||
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
|
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
|
||||||
|
|
||||||
def create_domain_challenge(self, domain: str, owner_user_id: int | None = None) -> dict[str, str]:
|
def create_domain_challenge(self, domain: str) -> dict[str, str]:
|
||||||
token = f"ff-ddns-{secrets.token_urlsafe(24)}"
|
token = f"ff-ddns-{secrets.token_urlsafe(24)}"
|
||||||
claim_secret = secrets.token_urlsafe(32)
|
claim_secret = secrets.token_urlsafe(32)
|
||||||
claim_hash = hash_lookup_token(claim_secret)
|
claim_hash = hash_lookup_token(claim_secret)
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
existing = conn.execute("SELECT owner_user_id, verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
|
existing = conn.execute("SELECT verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
|
||||||
if existing and existing[1]:
|
if existing and existing[0]:
|
||||||
raise sqlite3.IntegrityError("domain is already verified")
|
raise sqlite3.IntegrityError("domain is already verified")
|
||||||
if existing and owner_user_id is not None and existing[0] is not None and int(existing[0]) != owner_user_id:
|
|
||||||
raise sqlite3.IntegrityError("domain challenge belongs to another user")
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO domain_challenges(domain, token, claim_hash, owner_user_id, created_at, verified_at)
|
INSERT INTO domain_challenges(domain, token, claim_hash, created_at, verified_at)
|
||||||
VALUES (?, ?, ?, ?, ?, NULL)
|
VALUES (?, ?, ?, ?, NULL)
|
||||||
ON CONFLICT(domain) DO UPDATE SET
|
ON CONFLICT(domain) DO UPDATE SET
|
||||||
token=excluded.token,
|
token=excluded.token,
|
||||||
claim_hash=excluded.claim_hash,
|
claim_hash=excluded.claim_hash,
|
||||||
owner_user_id=excluded.owner_user_id,
|
|
||||||
created_at=excluded.created_at,
|
created_at=excluded.created_at,
|
||||||
verified_at=NULL
|
verified_at=NULL
|
||||||
""",
|
""",
|
||||||
(domain, token, claim_hash, owner_user_id, now),
|
(domain, token, claim_hash, now),
|
||||||
)
|
)
|
||||||
return {"domain": domain, "token": token, "claim_secret": claim_secret}
|
return {"domain": domain, "token": token, "claim_secret": claim_secret}
|
||||||
|
|
||||||
def verify_domain_challenge(self, domain: str, owner_user_id: int | None = None) -> dict[str, str | None] | None:
|
def verify_domain_challenge(self, domain: str) -> dict[str, str | None] | None:
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
sql = "SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?"
|
challenge = conn.execute("SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
|
||||||
params: tuple[str, ...] | tuple[str, int] = (domain,)
|
|
||||||
if owner_user_id is not None:
|
|
||||||
sql += " AND owner_user_id = ?"
|
|
||||||
params = (domain, owner_user_id)
|
|
||||||
challenge = conn.execute(sql, params).fetchone()
|
|
||||||
if not challenge:
|
if not challenge:
|
||||||
return None
|
return None
|
||||||
if owner_user_id is not None:
|
|
||||||
conn.execute("UPDATE domain_challenges SET verified_at = ? WHERE domain = ? AND owner_user_id = ?", (now, domain, owner_user_id))
|
|
||||||
else:
|
|
||||||
conn.execute("UPDATE domain_challenges SET verified_at = ? WHERE domain = ?", (now, domain))
|
conn.execute("UPDATE domain_challenges SET verified_at = ? WHERE domain = ?", (now, domain))
|
||||||
result = dict(challenge)
|
result = dict(challenge)
|
||||||
result["verified_at"] = now
|
result["verified_at"] = now
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_domain_challenge(
|
def get_domain_challenge(self, domain: str, claim_secret: str | None = None) -> dict[str, str | None] | None:
|
||||||
self,
|
|
||||||
domain: str,
|
|
||||||
owner_user_id: int | None = None,
|
|
||||||
claim_secret: str | None = None,
|
|
||||||
) -> dict[str, str | None] | None:
|
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
sql = "SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?"
|
sql = "SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?"
|
||||||
params: tuple[str, ...] | tuple[str, int] = (domain,)
|
params = (domain,)
|
||||||
if owner_user_id is not None:
|
if claim_secret is not None:
|
||||||
sql += " AND owner_user_id = ?"
|
|
||||||
params = (domain, owner_user_id)
|
|
||||||
elif claim_secret is not None:
|
|
||||||
sql += " AND claim_hash = ?"
|
sql += " AND claim_hash = ?"
|
||||||
params = (domain, hash_lookup_token(claim_secret))
|
params = (domain, hash_lookup_token(claim_secret))
|
||||||
challenge = conn.execute(sql, params).fetchone()
|
challenge = conn.execute(sql, params).fetchone()
|
||||||
@@ -256,86 +229,16 @@ class DdnsStore:
|
|||||||
result["claim_secret"] = claim_secret
|
result["claim_secret"] = claim_secret
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def is_domain_verified(
|
def is_domain_verified(self, domain: str, claim_secret: str | None = None) -> bool:
|
||||||
self,
|
|
||||||
domain: str,
|
|
||||||
owner_user_id: int | None = None,
|
|
||||||
claim_secret: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
|
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
|
||||||
params: tuple[str, ...] | tuple[str, int] = (domain,)
|
params = (domain,)
|
||||||
if owner_user_id is not None:
|
if claim_secret is not None:
|
||||||
sql += " AND owner_user_id = ?"
|
|
||||||
params = (domain, owner_user_id)
|
|
||||||
elif claim_secret is not None:
|
|
||||||
sql += " AND claim_hash = ?"
|
sql += " AND claim_hash = ?"
|
||||||
params = (domain, hash_lookup_token(claim_secret))
|
params = (domain, hash_lookup_token(claim_secret))
|
||||||
verified_at = conn.execute(sql, params).fetchone()
|
verified_at = conn.execute(sql, params).fetchone()
|
||||||
return bool(verified_at and verified_at[0])
|
return bool(verified_at and verified_at[0])
|
||||||
|
|
||||||
def count_user_accounts(self, owner_user_id: int) -> int:
|
|
||||||
with self.connect() as conn:
|
|
||||||
row = conn.execute("SELECT COUNT(*) FROM accounts WHERE owner_user_id = ? AND disabled = 0", (owner_user_id,)).fetchone()
|
|
||||||
return int(row[0])
|
|
||||||
|
|
||||||
def create_user(self, email: str, password: str, invite_code: str | None, require_invite: bool) -> dict[str, str | int]:
|
|
||||||
now = datetime.now(UTC).isoformat()
|
|
||||||
password_hash = hash_secret(password)
|
|
||||||
with self.connect() as conn:
|
|
||||||
if require_invite:
|
|
||||||
invite = conn.execute(
|
|
||||||
"SELECT code, used_at FROM invites WHERE code = ?",
|
|
||||||
(invite_code or "",),
|
|
||||||
).fetchone()
|
|
||||||
if not invite or invite[1]:
|
|
||||||
raise ValueError("invalid invite code")
|
|
||||||
cursor = conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO users(email, password_hash, created_at, disabled)
|
|
||||||
VALUES (?, ?, ?, 0)
|
|
||||||
""",
|
|
||||||
(email, password_hash, now),
|
|
||||||
)
|
|
||||||
user_id = int(cursor.lastrowid)
|
|
||||||
if require_invite and invite_code:
|
|
||||||
conn.execute("UPDATE invites SET used_at = ?, used_by_user_id = ? WHERE code = ?", (now, user_id, invite_code))
|
|
||||||
return {"id": user_id, "email": email}
|
|
||||||
|
|
||||||
def authenticate_user(self, email: str, password: str) -> dict[str, str | int] | None:
|
|
||||||
with self.connect() as conn:
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
user = conn.execute(
|
|
||||||
"SELECT id, email, password_hash, disabled FROM users WHERE email = ?",
|
|
||||||
(email,),
|
|
||||||
).fetchone()
|
|
||||||
if not user or user["disabled"] or not verify_secret(password, user["password_hash"]):
|
|
||||||
return None
|
|
||||||
return {"id": int(user["id"]), "email": str(user["email"])}
|
|
||||||
|
|
||||||
def get_user(self, user_id: int) -> dict[str, str | int] | None:
|
|
||||||
with self.connect() as conn:
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
user = conn.execute("SELECT id, email, disabled FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
||||||
if not user or user["disabled"]:
|
|
||||||
return None
|
|
||||||
return {"id": int(user["id"]), "email": str(user["email"])}
|
|
||||||
|
|
||||||
def create_invite(self) -> dict[str, str]:
|
|
||||||
code = secrets.token_urlsafe(18)
|
|
||||||
now = datetime.now(UTC).isoformat()
|
|
||||||
with self.connect() as conn:
|
|
||||||
conn.execute("INSERT INTO invites(code, created_at) VALUES (?, ?)", (code, now))
|
|
||||||
return {"code": code}
|
|
||||||
|
|
||||||
def list_invites(self) -> list[dict[str, str | None]]:
|
|
||||||
with self.connect() as conn:
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT code, created_at, used_at FROM invites ORDER BY created_at DESC LIMIT 50"
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def list_records(self) -> list[dict[str, str | None]]:
|
def list_records(self) -> list[dict[str, str | None]]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
@@ -451,16 +354,13 @@ class DdnsStore:
|
|||||||
token_hash TEXT NOT NULL,
|
token_hash TEXT NOT NULL,
|
||||||
update_slug TEXT NOT NULL UNIQUE,
|
update_slug TEXT NOT NULL UNIQUE,
|
||||||
management_hash TEXT UNIQUE,
|
management_hash TEXT UNIQUE,
|
||||||
owner_user_id INTEGER,
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
disabled INTEGER NOT NULL DEFAULT 0,
|
disabled INTEGER NOT NULL DEFAULT 0
|
||||||
FOREIGN KEY(owner_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
_ensure_column(conn, "accounts", "update_slug", "TEXT")
|
_ensure_column(conn, "accounts", "update_slug", "TEXT")
|
||||||
_ensure_column(conn, "accounts", "management_hash", "TEXT")
|
_ensure_column(conn, "accounts", "management_hash", "TEXT")
|
||||||
_ensure_column(conn, "accounts", "owner_user_id", "INTEGER")
|
|
||||||
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
|
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
|
||||||
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
|
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
|
||||||
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
|
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
|
||||||
@@ -473,37 +373,12 @@ class DdnsStore:
|
|||||||
domain TEXT PRIMARY KEY,
|
domain TEXT PRIMARY KEY,
|
||||||
token TEXT NOT NULL,
|
token TEXT NOT NULL,
|
||||||
claim_hash TEXT,
|
claim_hash TEXT,
|
||||||
owner_user_id INTEGER,
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
verified_at TEXT,
|
verified_at TEXT
|
||||||
FOREIGN KEY(owner_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
|
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
|
||||||
_ensure_column(conn, "domain_challenges", "owner_user_id", "INTEGER")
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
disabled INTEGER NOT NULL DEFAULT 0
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS invites (
|
|
||||||
code TEXT PRIMARY KEY,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
used_at TEXT,
|
|
||||||
used_by_user_id INTEGER,
|
|
||||||
FOREIGN KEY(used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _default_username(hostname: str) -> str:
|
def _default_username(hostname: str) -> str:
|
||||||
|
|||||||
+2
-71
@@ -7,15 +7,6 @@ import router_dyndns.ddns_service as ddns_service
|
|||||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||||
|
|
||||||
|
|
||||||
def login_test_user(client: TestClient) -> None:
|
|
||||||
response = client.post(
|
|
||||||
"/register",
|
|
||||||
data={"email": "[email protected]", "password": "very-secure-password"},
|
|
||||||
follow_redirects=False,
|
|
||||||
)
|
|
||||||
assert response.status_code == 303
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
@@ -194,39 +185,6 @@ def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_registration_is_open_by_default_for_optional_accounts(tmp_path: Path) -> None:
|
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/register",
|
|
||||||
data={"email": "[email protected]", "password": "very-secure-password"},
|
|
||||||
follow_redirects=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 303
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_invite_allows_registration(tmp_path: Path) -> None:
|
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
|
||||||
store = DdnsStore(database_path)
|
|
||||||
invite = store.create_invite()
|
|
||||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/register",
|
|
||||||
data={
|
|
||||||
"email": "[email protected]",
|
|
||||||
"password": "very-secure-password",
|
|
||||||
"invite": invite["code"],
|
|
||||||
},
|
|
||||||
follow_redirects=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 303
|
|
||||||
|
|
||||||
|
|
||||||
def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None:
|
def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
@@ -234,11 +192,9 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
|||||||
admin_password="admin",
|
admin_password="admin",
|
||||||
hostname_suffix="ddns.example.net",
|
hostname_suffix="ddns.example.net",
|
||||||
public_base_url="http://ddns.example.net",
|
public_base_url="http://ddns.example.net",
|
||||||
require_invite=False,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
login_test_user(client)
|
|
||||||
|
|
||||||
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
|
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
|
||||||
|
|
||||||
@@ -287,9 +243,8 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
|
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin", require_invite=False))
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
login_test_user(client)
|
|
||||||
|
|
||||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
|
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
|
||||||
|
|
||||||
@@ -303,14 +258,12 @@ def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) ->
|
|||||||
database_path=tmp_path / "ddns.sqlite3",
|
database_path=tmp_path / "ddns.sqlite3",
|
||||||
admin_password="admin",
|
admin_password="admin",
|
||||||
public_base_url="http://ddns.example.net",
|
public_base_url="http://ddns.example.net",
|
||||||
require_invite=False,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
login_test_user(client)
|
|
||||||
|
|
||||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||||
challenge_data = store.create_domain_challenge("example.net", owner_user_id=1)
|
challenge_data = store.create_domain_challenge("example.net")
|
||||||
verify = client.post(
|
verify = client.post(
|
||||||
"/verify-domain",
|
"/verify-domain",
|
||||||
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
|
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||||
@@ -441,7 +394,6 @@ def test_api_custom_hostname_requires_verified_domain(tmp_path: Path, monkeypatc
|
|||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
login_test_user(client)
|
|
||||||
|
|
||||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||||
assert challenge.status_code == 201
|
assert challenge.status_code == 201
|
||||||
@@ -527,7 +479,6 @@ def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeyp
|
|||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
login_test_user(client)
|
|
||||||
|
|
||||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||||
verified = client.post(
|
verified = client.post(
|
||||||
@@ -540,25 +491,6 @@ def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeyp
|
|||||||
assert overwrite.status_code == 409
|
assert overwrite.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_logged_in_hostname_quota_is_enforced(tmp_path: Path) -> None:
|
|
||||||
app = make_app(
|
|
||||||
DdnsSettings(
|
|
||||||
database_path=tmp_path / "ddns.sqlite3",
|
|
||||||
admin_password="admin",
|
|
||||||
hostname_suffix="ddns.example.net",
|
|
||||||
max_hostnames_per_user=1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
login_test_user(client)
|
|
||||||
|
|
||||||
first = client.post("/accounts", data={"mode": "managed", "username": "one"})
|
|
||||||
second = client.post("/accounts", data={"mode": "managed", "username": "two"})
|
|
||||||
|
|
||||||
assert first.status_code == 200
|
|
||||||
assert second.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
|
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||||
app = make_app(
|
app = make_app(
|
||||||
@@ -572,7 +504,6 @@ def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, mon
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
login_test_user(client)
|
|
||||||
|
|
||||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||||
client.post(
|
client.post(
|
||||||
|
|||||||
Reference in new issue
Block a user