Archived
Simplify onboarding with magic domain claims
This commit is contained in:
1 parent
7ce4f6816f
commit
86060d6bdc
9 files changed
+108
-55
No files matched your search
@@ -12,18 +12,14 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control
|
|||||||
|
|
||||||
- **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure.
|
- **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure.
|
||||||
- **FRITZ!Box compatible**: copy the generated `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` straight into the German FRITZ!Box DynDNS form.
|
- **FRITZ!Box compatible**: copy the generated `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` straight into the German FRITZ!Box DynDNS form.
|
||||||
- **No account required for basic hostnames**: generate a cryptographically random provider hostname and private management link.
|
- **No registration required**: provider hostnames and custom-domain setup both use private magic links.
|
||||||
- **Custom domain support**: users enter a domain, add a DNS TXT record, then press a button to verify ownership before credentials are issued.
|
- **Custom domain support**: users enter a domain, save the private claim link, add a DNS TXT record, then press a button to verify ownership before credentials are issued.
|
||||||
- **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support.
|
- **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support.
|
||||||
- **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
|
- **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
|
||||||
- **API first**: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under `/api/v1`.
|
- **API first**: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under `/api/v1`.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
### Dashboard
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Generate Router Credentials
|
### Generate Router Credentials
|
||||||
|
|
||||||

|

|
||||||
@@ -63,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 | Anonymous magic links for simple hostnames, login workspace for custom domains |
|
| Auth model | Private magic links for hostnames and custom-domain claims; optional account workspace for dashboards |
|
||||||
| Operations | Rate limiting, update logs, cleanup jobs, Docker, Caddy example |
|
| Operations | Rate limiting, update logs, cleanup jobs, Docker, Caddy example |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -162,11 +158,12 @@ Prefer `/u/<slug>` for managed service use because the router request cannot cha
|
|||||||
Custom domains are intentionally simple:
|
Custom domains are intentionally simple:
|
||||||
|
|
||||||
1. Enter the domain.
|
1. Enter the domain.
|
||||||
2. Add the generated TXT record at your DNS provider.
|
2. Save the private claim link.
|
||||||
3. Press **I added it, check DNS**.
|
3. Add the generated TXT record at your DNS provider.
|
||||||
4. Create router credentials after the TXT record verifies.
|
4. Press **I added it, check DNS**.
|
||||||
|
5. Create router credentials after the TXT record verifies.
|
||||||
|
|
||||||
RouterPulse stores the domain claim in SQLite and only creates hostnames below verified, publishable zones.
|
RouterPulse stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. The private claim link is the bearer credential for returning later after DNS propagation.
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
@@ -177,9 +174,9 @@ The JSON API is available under `/api/v1`.
|
|||||||
| `POST /api/v1/hostnames/magic` | Create an anonymous provider-owned hostname. |
|
| `POST /api/v1/hostnames/magic` | Create an anonymous provider-owned hostname. |
|
||||||
| `GET /api/v1/management/{management_slug}` | Inspect a hostname by private management link. |
|
| `GET /api/v1/management/{management_slug}` | Inspect a hostname by private management link. |
|
||||||
| `DELETE /api/v1/management/{management_slug}` | Delete a hostname and its DNS records. |
|
| `DELETE /api/v1/management/{management_slug}` | Delete a hostname and its DNS records. |
|
||||||
| `POST /api/v1/domains/challenges` | Create a TXT challenge. Requires login. |
|
| `POST /api/v1/domains/challenges` | Create a TXT challenge and private claim secret. |
|
||||||
| `POST /api/v1/domains/verify` | Verify a TXT challenge. Requires login. |
|
| `POST /api/v1/domains/verify` | Verify a TXT challenge with the claim secret. |
|
||||||
| `POST /api/v1/hostnames/custom` | Create a custom hostname below a verified domain. Requires login. |
|
| `POST /api/v1/hostnames/custom` | Create a custom hostname below a verified domain with the claim secret. |
|
||||||
| `GET /api/v1/updates/{update_slug}` | JSON update endpoint for routers or automation. |
|
| `GET /api/v1/updates/{update_slug}` | JSON update endpoint for routers or automation. |
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
+37
-28
@@ -181,6 +181,13 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
return _render_management_page(service, settings, management_slug, account)
|
return _render_management_page(service, settings, management_slug, account)
|
||||||
|
|
||||||
|
@app.get("/d/{claim_secret}", response_class=HTMLResponse)
|
||||||
|
def manage_domain_claim(claim_secret: str) -> str:
|
||||||
|
challenge = store.get_domain_challenge_by_secret(claim_secret)
|
||||||
|
if not challenge:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
return _render_public_home(service, settings, None, None, challenge)
|
||||||
|
|
||||||
@app.post("/m/{management_slug}/delete")
|
@app.post("/m/{management_slug}/delete")
|
||||||
def delete_magic(management_slug: str) -> Response:
|
def delete_magic(management_slug: str) -> Response:
|
||||||
account = store.get_account_by_management_slug(management_slug)
|
account = store.get_account_by_management_slug(management_slug)
|
||||||
@@ -212,46 +219,52 @@ 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)
|
user = await run_in_threadpool(current_user, request)
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="authentication required")
|
|
||||||
form = _parse_form(await request.body())
|
form = _parse_form(await request.body())
|
||||||
try:
|
try:
|
||||||
challenge = await run_in_threadpool(service.create_domain_challenge, _first_form_value(form, "domain"), int(user["id"]))
|
challenge = await run_in_threadpool(
|
||||||
|
service.create_domain_challenge,
|
||||||
|
_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
|
||||||
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, challenge)
|
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)
|
||||||
|
|
||||||
@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)
|
user = await run_in_threadpool(current_user, request)
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="authentication required")
|
|
||||||
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")
|
||||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, int(user["id"]))
|
owner_user_id = int(user["id"]) if user else None
|
||||||
|
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."
|
||||||
|
if not user:
|
||||||
|
return _render_public_home(service, settings, None, message, challenge)
|
||||||
return _render_user_home(
|
return _render_user_home(
|
||||||
service,
|
service,
|
||||||
settings,
|
settings,
|
||||||
user,
|
user,
|
||||||
store.list_accounts(int(user["id"])),
|
store.list_accounts(int(user["id"])),
|
||||||
None,
|
None,
|
||||||
"TXT record not found yet. DNS propagation can take a few minutes.",
|
message,
|
||||||
challenge,
|
challenge,
|
||||||
)
|
)
|
||||||
|
if not user:
|
||||||
|
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)
|
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)
|
user = await run_in_threadpool(current_user, request)
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="authentication required")
|
|
||||||
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"]))
|
account = await run_in_threadpool(service.create_managed_account, username, int(user["id"]) if user else None)
|
||||||
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(
|
||||||
@@ -259,8 +272,10 @@ 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"]),
|
int(user["id"]) if user else None,
|
||||||
)
|
)
|
||||||
|
if not user:
|
||||||
|
return _render_public_home(service, settings, account, None)
|
||||||
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), account, None, None)
|
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), account, None, None)
|
||||||
|
|
||||||
@app.post("/register")
|
@app.post("/register")
|
||||||
@@ -428,6 +443,7 @@ def _render_public_home(
|
|||||||
suffix = html.escape(settings.hostname_suffix or "your DynDNS domain")
|
suffix = html.escape(settings.hostname_suffix or "your DynDNS domain")
|
||||||
created_html = _credentials_panel(service, created_account)
|
created_html = _credentials_panel(service, created_account)
|
||||||
message_html = _message_band(message) if message and not challenge else ""
|
message_html = _message_band(message) if message and not challenge else ""
|
||||||
|
challenge_html = _custom_domain_flow(service, challenge, message)
|
||||||
return _page(
|
return _page(
|
||||||
"router-dyndns",
|
"router-dyndns",
|
||||||
f"""
|
f"""
|
||||||
@@ -440,7 +456,7 @@ def _render_public_home(
|
|||||||
<p class="lead">Create a secure update endpoint under {suffix}. Provider hostnames are anonymous; custom domains require DNS proof.</p>
|
<p class="lead">Create a secure update endpoint under {suffix}. Provider hostnames are anonymous; custom domains require DNS proof.</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button" href="#create">Create hostname</a>
|
<a class="button" href="#create">Create hostname</a>
|
||||||
<a class="button secondary" href="/login">Use custom domain</a>
|
<a class="button secondary" href="#custom-domain">Use custom domain</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -461,16 +477,7 @@ def _render_public_home(
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="section section-dark">
|
{challenge_html}
|
||||||
<div class="container split-row">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Custom domains</p>
|
|
||||||
<h2>Use your own domain after TXT verification.</h2>
|
|
||||||
<p class="section-copy">Enter your domain, add the TXT record we show you, then press the check button. We verify public DNS before credentials are issued.</p>
|
|
||||||
</div>
|
|
||||||
<a class="button secondary-on-dark" href="/login">Set up domain</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer-links">
|
<div class="container footer-links">
|
||||||
<a href="/docs">API docs</a>
|
<a href="/docs">API docs</a>
|
||||||
@@ -563,8 +570,8 @@ def _render_auth_page(settings: DdnsSettings, message: str | None) -> str:
|
|||||||
<section class="hero-band compact">
|
<section class="hero-band compact">
|
||||||
<div class="container hero-inner">
|
<div class="container hero-inner">
|
||||||
<p class="eyebrow">Custom domains</p>
|
<p class="eyebrow">Custom domains</p>
|
||||||
<h1>Set up a verified domain.</h1>
|
<h1>Optional account workspace.</h1>
|
||||||
<p class="lead">Create a small private workspace, enter your domain, add the TXT record we generate, then check public DNS.</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{_message_band(message) if message else ""}
|
{_message_band(message) if message else ""}
|
||||||
@@ -605,7 +612,7 @@ def _render_public_page(
|
|||||||
) -> str:
|
) -> str:
|
||||||
if user:
|
if user:
|
||||||
return _render_user_home(service, settings, user, accounts, created_account, message, challenge)
|
return _render_user_home(service, settings, user, accounts, created_account, message, challenge)
|
||||||
return _render_auth_page(settings, message)
|
return _render_public_home(service, settings, created_account, message, challenge)
|
||||||
|
|
||||||
|
|
||||||
def _top_nav(label: str | None = None, show_logout: bool = False, links: str = "") -> str:
|
def _top_nav(label: str | None = None, show_logout: bool = False, links: str = "") -> str:
|
||||||
@@ -615,7 +622,7 @@ def _top_nav(label: str | None = None, show_logout: bool = False, links: str = "
|
|||||||
if show_logout:
|
if show_logout:
|
||||||
account_html += '<form method="post" action="/logout"><button class="nav-button" type="submit">Logout</button></form>'
|
account_html += '<form method="post" action="/logout"><button class="nav-button" type="submit">Logout</button></form>'
|
||||||
elif not links:
|
elif not links:
|
||||||
account_html += '<a class="nav-button" href="/login">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">
|
||||||
<div class="container nav-inner">
|
<div class="container nav-inner">
|
||||||
@@ -637,6 +644,7 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
|
|||||||
token = str(challenge["token"])
|
token = str(challenge["token"])
|
||||||
claim_secret = html.escape(str(challenge.get("claim_secret") or ""))
|
claim_secret = html.escape(str(challenge.get("claim_secret") or ""))
|
||||||
verification_name = service.verification_name(str(challenge["domain"]))
|
verification_name = service.verification_name(str(challenge["domain"]))
|
||||||
|
claim_url = service.domain_claim_url(challenge)
|
||||||
status = html.escape(message or "Add this TXT record at your DNS provider, then press the check button.")
|
status = html.escape(message or "Add this TXT record at your DNS provider, then press the check button.")
|
||||||
challenge_html = f"""
|
challenge_html = f"""
|
||||||
<div class="step-card">
|
<div class="step-card">
|
||||||
@@ -645,6 +653,7 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
|
|||||||
<p class="note">{status}</p>
|
<p class="note">{status}</p>
|
||||||
{_copy_row("TXT name", verification_name)}
|
{_copy_row("TXT name", verification_name)}
|
||||||
{_copy_row("TXT value", token)}
|
{_copy_row("TXT value", token)}
|
||||||
|
{_copy_row("Private claim link", claim_url)}
|
||||||
<form method="post" action="/verify-domain" class="inline-form">
|
<form method="post" action="/verify-domain" class="inline-form">
|
||||||
<input type="hidden" name="domain" value="{domain}">
|
<input type="hidden" name="domain" value="{domain}">
|
||||||
<input type="hidden" name="claim_secret" value="{claim_secret}">
|
<input type="hidden" name="claim_secret" value="{claim_secret}">
|
||||||
@@ -680,7 +689,7 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<section class="section section-dark">
|
<section class="section section-dark" id="custom-domain">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -77,13 +77,7 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
|
|||||||
user_id = verify_session_cookie(request.cookies.get("ddns_session"), settings.session_secret)
|
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
|
return store.get_user(user_id) if user_id is not None else None
|
||||||
|
|
||||||
def require_user(request: Request) -> dict[str, str | int]:
|
optional_user_dependency = Depends(optional_user)
|
||||||
user = optional_user(request)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="authentication required")
|
|
||||||
return user
|
|
||||||
|
|
||||||
user_dependency = Depends(require_user)
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/hostnames/magic",
|
"/hostnames/magic",
|
||||||
@@ -138,11 +132,11 @@ 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] = user_dependency,
|
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"]))
|
challenge = await run_in_threadpool(service.create_domain_challenge, 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
|
||||||
return DomainChallengeResponse(
|
return DomainChallengeResponse(
|
||||||
@@ -161,10 +155,10 @@ 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] = user_dependency,
|
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"]))
|
found, challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret, int(user["id"]) if user else None)
|
||||||
if not found:
|
if not found:
|
||||||
return VerifyDomainResponse(
|
return VerifyDomainResponse(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
@@ -189,14 +183,14 @@ 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] = user_dependency,
|
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"]),
|
int(user["id"]) if user else None,
|
||||||
)
|
)
|
||||||
return _credentials_response(service, account)
|
return _credentials_response(service, account)
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ class DdnsService:
|
|||||||
slug = urllib.parse.quote(account["management_slug"])
|
slug = urllib.parse.quote(account["management_slug"])
|
||||||
return f"{base}/m/{slug}"
|
return f"{base}/m/{slug}"
|
||||||
|
|
||||||
|
def domain_claim_url(self, challenge: dict[str, str | None]) -> str:
|
||||||
|
base = self.settings.public_base_url.rstrip("/")
|
||||||
|
secret = urllib.parse.quote(str(challenge["claim_secret"]))
|
||||||
|
return f"{base}/d/{secret}"
|
||||||
|
|
||||||
def random_managed_hostname(self) -> str:
|
def random_managed_hostname(self) -> str:
|
||||||
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
||||||
if not suffix:
|
if not suffix:
|
||||||
|
|||||||
@@ -243,6 +243,19 @@ class DdnsStore:
|
|||||||
challenge = conn.execute(sql, params).fetchone()
|
challenge = conn.execute(sql, params).fetchone()
|
||||||
return dict(challenge) if challenge else None
|
return dict(challenge) if challenge else None
|
||||||
|
|
||||||
|
def get_domain_challenge_by_secret(self, claim_secret: str) -> dict[str, str | None] | None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
challenge = conn.execute(
|
||||||
|
"SELECT domain, token, verified_at FROM domain_challenges WHERE claim_hash = ?",
|
||||||
|
(hash_lookup_token(claim_secret),),
|
||||||
|
).fetchone()
|
||||||
|
if not challenge:
|
||||||
|
return None
|
||||||
|
result = dict(challenge)
|
||||||
|
result["claim_secret"] = claim_secret
|
||||||
|
return result
|
||||||
|
|
||||||
def is_domain_verified(
|
def is_domain_verified(
|
||||||
self,
|
self,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -326,6 +327,40 @@ def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) ->
|
|||||||
assert "Update-URL:" in response.text
|
assert "Update-URL:" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||||
|
app = make_app(
|
||||||
|
DdnsSettings(
|
||||||
|
database_path=tmp_path / "ddns.sqlite3",
|
||||||
|
admin_password="admin",
|
||||||
|
public_base_url="http://ddns.example.net",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
challenge = client.post("/request-domain", data={"domain": "example.net"})
|
||||||
|
assert challenge.status_code == 200
|
||||||
|
assert "Private claim link" in challenge.text
|
||||||
|
assert "/d/" in challenge.text
|
||||||
|
|
||||||
|
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
|
||||||
|
claim_page = client.get(f"/d/{claim_secret}")
|
||||||
|
assert claim_page.status_code == 200
|
||||||
|
assert "TXT value" in claim_page.text
|
||||||
|
|
||||||
|
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
|
||||||
|
assert verify.status_code == 200
|
||||||
|
assert "Create credentials" in verify.text
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
"/accounts",
|
||||||
|
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||||
|
)
|
||||||
|
assert created.status_code == 200
|
||||||
|
assert "home.example.net" in created.text
|
||||||
|
assert "Update-URL:" in created.text
|
||||||
|
|
||||||
|
|
||||||
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
||||||
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)
|
||||||
|
|||||||
Reference in new issue
Block a user