Archived
Initial router DynDNS service
This commit is contained in:
commit
3ad0a9503d
18 files changed
+3092
No files matched your search
@@ -0,0 +1,14 @@
|
||||
DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password
|
||||
DDNS_SESSION_SECRET=change-me-long-random-session-secret
|
||||
DDNS_PUBLIC_BASE_URL=https://ddns.example.net
|
||||
DDNS_HOSTNAME_SUFFIX=ddns.example.net
|
||||
DDNS_DATABASE=/data/ddns.sqlite3
|
||||
DDNS_DNS_PROVIDER=cloudflare
|
||||
DDNS_DNS_ZONES=ddns.example.net
|
||||
DDNS_REQUIRE_DNS_PROVIDER=1
|
||||
DDNS_CLOUDFLARE_API_TOKEN=change-me-cloudflare-token
|
||||
DDNS_CLOUDFLARE_ZONE_ID=change-me-cloudflare-zone-id
|
||||
DDNS_TTL=60
|
||||
DDNS_RATE_LIMIT_PER_MINUTE=60
|
||||
DDNS_CLEANUP_CHALLENGE_HOURS=72
|
||||
DDNS_TRUSTED_PROXY_IPS=127.0.0.1,::1
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.sqlite3
|
||||
*.db
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,4 @@
|
||||
ddns.example.net {
|
||||
encode zstd gzip
|
||||
reverse_proxy ddns:8080
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md /app/
|
||||
COPY router_dyndns /app/router_dyndns
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["router-dyndns", "serve", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,162 @@
|
||||
# router-dyndns
|
||||
|
||||
A small self-hosted DynDNS service for FRITZ!Box and compatible routers.
|
||||
|
||||
It gives users FRITZ!Box-ready DynDNS settings without requiring a full account for provider-owned hostnames. For custom domains, users verify domain ownership with a DNS TXT challenge before creating hostnames.
|
||||
|
||||
## Features
|
||||
|
||||
- FastAPI app with `/docs`, `/redoc`, and `/openapi.json`.
|
||||
- Anonymous magic URLs for provider-owned hostnames.
|
||||
- Optional user sessions for custom-domain verification and hostname quotas.
|
||||
- FRITZ!Box-compatible update URLs.
|
||||
- SQLite persistence with WAL mode.
|
||||
- SQLite-backed rate limiting for public creation and update endpoints.
|
||||
- Cloudflare DNS publishing.
|
||||
- RFC 2136 DNS publishing.
|
||||
- DNS publish-before-store update behavior.
|
||||
- Per-host update locking inside one server process.
|
||||
- Custom-domain TXT verification.
|
||||
- Trusted proxy configuration for `X-Forwarded-For`.
|
||||
- Docker and Caddy examples.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Run quality checks:
|
||||
|
||||
```bash
|
||||
ruff check router_dyndns tests
|
||||
pytest
|
||||
```
|
||||
|
||||
## Run Locally
|
||||
|
||||
```bash
|
||||
export DDNS_ADMIN_PASSWORD='long-random-admin-password'
|
||||
export DDNS_SESSION_SECRET='another-long-random-secret'
|
||||
export DDNS_PUBLIC_BASE_URL='https://ddns.example.net'
|
||||
export DDNS_HOSTNAME_SUFFIX='ddns.example.net'
|
||||
export DDNS_DATABASE='./ddns.sqlite3'
|
||||
export DDNS_DNS_PROVIDER='cloudflare'
|
||||
export DDNS_DNS_ZONES='ddns.example.net'
|
||||
export DDNS_REQUIRE_DNS_PROVIDER=1
|
||||
export DDNS_CLOUDFLARE_API_TOKEN='cloudflare-api-token'
|
||||
export DDNS_CLOUDFLARE_ZONE_ID='cloudflare-zone-id'
|
||||
export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1'
|
||||
|
||||
router-dyndns serve --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- Customer UI: `https://ddns.example.net/`
|
||||
- Admin UI: `https://ddns.example.net/admin`
|
||||
- Swagger UI: `https://ddns.example.net/docs`
|
||||
- ReDoc: `https://ddns.example.net/redoc`
|
||||
|
||||
Admin auth uses HTTP Basic auth. Use any username and `DDNS_ADMIN_PASSWORD` as the password.
|
||||
|
||||
## FRITZ!Box Setup
|
||||
|
||||
Users receive these fields:
|
||||
|
||||
- `Update-URL`
|
||||
- `Domainnamen`
|
||||
- `Benutzername`
|
||||
- `Kennwort`
|
||||
|
||||
The generated update URL uses FRITZ!Box placeholders:
|
||||
|
||||
```text
|
||||
/u/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>
|
||||
```
|
||||
|
||||
The compatibility endpoint is also available:
|
||||
|
||||
```text
|
||||
/nic/update?hostname=<domain>&myip=<ipaddr>&myipv6=<ip6addr>&username=<username>&password=<pass>
|
||||
```
|
||||
|
||||
The `/u/<slug>` endpoint is safer for public service use because the user cannot alter the hostname in the router request.
|
||||
|
||||
## HTTP API
|
||||
|
||||
The API is versioned under `/api/v1`.
|
||||
|
||||
Core endpoints:
|
||||
|
||||
- `POST /api/v1/hostnames/magic`: create a provider-owned random hostname.
|
||||
- `GET /api/v1/management/{management_slug}`: inspect a hostname by private management link.
|
||||
- `DELETE /api/v1/management/{management_slug}`: delete a hostname and DNS records.
|
||||
- `POST /api/v1/domains/challenges`: create a TXT challenge. Requires login.
|
||||
- `POST /api/v1/domains/verify`: verify a TXT challenge. Requires login.
|
||||
- `POST /api/v1/hostnames/custom`: create a custom hostname below a verified and publishable domain. Requires login.
|
||||
- `GET /api/v1/updates/{update_slug}`: JSON update endpoint.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -sS https://ddns.example.net/api/v1/hostnames/magic \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"home-router"}'
|
||||
```
|
||||
|
||||
## DNS Publishing
|
||||
|
||||
For public service use, configure a real DNS backend and set:
|
||||
|
||||
```bash
|
||||
export DDNS_REQUIRE_DNS_PROVIDER=1
|
||||
```
|
||||
|
||||
Cloudflare:
|
||||
|
||||
```bash
|
||||
export DDNS_DNS_PROVIDER=cloudflare
|
||||
export DDNS_DNS_ZONES=ddns.example.net
|
||||
export DDNS_CLOUDFLARE_API_TOKEN='cloudflare-api-token'
|
||||
export DDNS_CLOUDFLARE_ZONE_ID='cloudflare-zone-id'
|
||||
export DDNS_TTL=60
|
||||
```
|
||||
|
||||
RFC 2136:
|
||||
|
||||
```bash
|
||||
export DDNS_DNS_PROVIDER=rfc2136
|
||||
export DDNS_DNS_ZONES=ddns.example.net
|
||||
export DDNS_RFC2136_SERVER=127.0.0.1
|
||||
export DDNS_RFC2136_ZONE=ddns.example.net
|
||||
export DDNS_RFC2136_KEY_NAME=ddns-key
|
||||
export DDNS_RFC2136_KEY_SECRET='base64-tsig-secret'
|
||||
export DDNS_TTL=60
|
||||
```
|
||||
|
||||
`DDNS_DNS_ZONES` defines which DNS zones this service may publish. A user proving TXT control of a domain is not enough; the hostname must also be inside a zone your DNS backend can update.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Copy `.env.example` to `.env` and set real secrets first.
|
||||
|
||||
## Operations
|
||||
|
||||
- Run behind HTTPS.
|
||||
- Use long random values for `DDNS_ADMIN_PASSWORD` and `DDNS_SESSION_SECRET`.
|
||||
- Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers.
|
||||
- Back up the SQLite database.
|
||||
- Run one Uvicorn worker with SQLite. The per-host update lock is process-local.
|
||||
|
||||
SQLite backup example:
|
||||
|
||||
```bash
|
||||
sqlite3 /data/ddns.sqlite3 ".backup '/backups/router-dyndns.sqlite3'"
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
ddns:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ddns-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2).read()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
ddns-data:
|
||||
@@ -0,0 +1,41 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "router-dyndns"
|
||||
version = "0.1.0"
|
||||
description = "A small self-hosted DynDNS service for FRITZ!Box and compatible routers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"dnspython>=2.6",
|
||||
"fastapi>=0.111",
|
||||
"pydantic>=2.7",
|
||||
"uvicorn[standard]>=0.30",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"httpx>=0.28",
|
||||
"pytest>=8.2",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
router-dyndns = "router_dyndns.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["router_dyndns"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
ignore = ["E501"]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Self-hosted DynDNS service for FRITZ!Box and compatible routers."""
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .ddns import DdnsSettings, make_app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="router-dyndns")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
render_url = subparsers.add_parser("render-fritz-url", help="Render a FRITZ!Box custom DynDNS update URL.")
|
||||
render_url.add_argument("--base-url", required=True, help="Public base URL of this DDNS service.")
|
||||
render_url.add_argument("--host", required=True, help="DNS hostname configured in FRITZ!Box.")
|
||||
render_url.add_argument("--include-ipv6", action="store_true")
|
||||
|
||||
serve = subparsers.add_parser("serve", help="Run the DDNS HTTP service.")
|
||||
serve.add_argument("--host", default="0.0.0.0")
|
||||
serve.add_argument("--port", type=int, default=8080)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "render-fritz-url":
|
||||
print(_render_fritz_url(args.base_url, args.host, args.include_ipv6))
|
||||
elif args.command == "serve":
|
||||
import uvicorn
|
||||
|
||||
settings = DdnsSettings.from_env()
|
||||
if not settings.admin_password:
|
||||
raise SystemExit("DDNS_ADMIN_PASSWORD must be set.")
|
||||
uvicorn.run(make_app(settings), host=args.host, port=args.port)
|
||||
|
||||
|
||||
def _render_fritz_url(base_url: str, host: str, include_ipv6: bool) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
url = f"{base}/nic/update?hostname=<domain>&myip=<ipaddr>&username=<username>&password=<pass>"
|
||||
if include_ipv6:
|
||||
url += "&myipv6=<ip6addr>"
|
||||
return url.replace("<domain>", host)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import secrets
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from .ddns_api import create_api_router
|
||||
from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns
|
||||
from .ddns_models import DdnsSettings, UpdateResult
|
||||
from .ddns_security import (
|
||||
admin_csrf_token,
|
||||
client_ip,
|
||||
credentials_from_basic_auth,
|
||||
is_rate_limited_path,
|
||||
normalize_email,
|
||||
require_admin_csrf,
|
||||
set_session_cookie,
|
||||
verify_session_cookie,
|
||||
)
|
||||
from .ddns_service import DdnsService, normalize_domain, normalize_hostname
|
||||
from .ddns_store import DdnsStore
|
||||
|
||||
__all__ = [
|
||||
"DdnsSettings",
|
||||
"DdnsStore",
|
||||
"UpdateResult",
|
||||
"delete_dns_records",
|
||||
"make_app",
|
||||
"publish_dns",
|
||||
]
|
||||
|
||||
|
||||
OPENAPI_TAGS = [
|
||||
{
|
||||
"name": "hostnames",
|
||||
"description": "Create provider-owned or verified custom DynDNS hostnames.",
|
||||
},
|
||||
{
|
||||
"name": "domains",
|
||||
"description": "Issue and verify DNS TXT challenges for custom domains.",
|
||||
},
|
||||
{
|
||||
"name": "updates",
|
||||
"description": "Router-facing IP update endpoints for FRITZ!Box and compatible clients.",
|
||||
},
|
||||
{
|
||||
"name": "management",
|
||||
"description": "Bearer-link management for generated hostnames.",
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Operator-only HTML and JSON endpoints protected by the admin password.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
||||
settings = settings or DdnsSettings.from_env()
|
||||
if not settings.admin_password and 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):
|
||||
raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
|
||||
|
||||
store = DdnsStore(settings.database_path)
|
||||
store.cleanup(settings.cleanup_challenge_hours)
|
||||
service = DdnsService(settings, store)
|
||||
app = FastAPI(
|
||||
title="router_dyndns DDNS",
|
||||
summary="A small self-hosted DynDNS provider for FRITZ!Box routers.",
|
||||
description=(
|
||||
"Generate cryptographically random DynDNS endpoints, verify custom domains with DNS TXT "
|
||||
"records, and publish A/AAAA records through Cloudflare or RFC 2136."
|
||||
),
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
)
|
||||
app.include_router(create_api_router(settings, store, service))
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_middleware(request: Request, call_next):
|
||||
if is_rate_limited_path(request.url.path):
|
||||
key = f"{client_ip(request, settings)}:{request.url.path}"
|
||||
allowed = await run_in_threadpool(store.allow_rate_limit, key, settings.rate_limit_per_minute)
|
||||
if not allowed:
|
||||
return PlainTextResponse("rate limit exceeded", status_code=429)
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "same-origin"
|
||||
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; style-src 'unsafe-inline' 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||
)
|
||||
if settings.public_base_url.startswith("https://"):
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
|
||||
def authenticate_update(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
username: str | None = Query(default=None),
|
||||
password: str | None = Query(default=None),
|
||||
pass_: str | None = Query(default=None, alias="pass"),
|
||||
passwd: str | None = Query(default=None),
|
||||
) -> tuple[str | None, str | None]:
|
||||
supplied_user = username
|
||||
supplied_token = password or pass_ or passwd
|
||||
if authorization and authorization.lower().startswith("basic "):
|
||||
supplied_user, supplied_token = credentials_from_basic_auth(authorization)
|
||||
return supplied_user, supplied_token
|
||||
|
||||
def authenticate_admin(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
if not settings.admin_password:
|
||||
raise HTTPException(status_code=500, detail="DDNS_ADMIN_PASSWORD is required")
|
||||
|
||||
supplied = None
|
||||
if authorization and authorization.lower().startswith("basic "):
|
||||
_, supplied = credentials_from_basic_auth(authorization)
|
||||
|
||||
if not supplied or not secrets.compare_digest(supplied, settings.admin_password):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="admin authentication required",
|
||||
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(
|
||||
request: Request,
|
||||
hostname: str,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> str | PlainTextResponse:
|
||||
try:
|
||||
return service.apply_update(request, hostname, myip, ipaddr, myipv6, ip6addr).result.dyndns_response
|
||||
except Exception:
|
||||
return PlainTextResponse("911 dns publish failed", status_code=500)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
return _render_magic_page(service, settings, None, None)
|
||||
|
||||
@app.post("/magic", response_class=HTMLResponse)
|
||||
async def magic_account(request: Request) -> str:
|
||||
form = _parse_form(await request.body())
|
||||
username = _first_form_value(form, "username") or None
|
||||
account = await run_in_threadpool(service.create_managed_account, username, None)
|
||||
return _render_magic_page(service, settings, account, None)
|
||||
|
||||
@app.get("/m/{management_slug}", response_class=HTMLResponse)
|
||||
def manage_magic(management_slug: str) -> str:
|
||||
account = store.get_account_by_management_slug(management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _render_management_page(service, settings, management_slug, account)
|
||||
|
||||
@app.post("/m/{management_slug}/delete")
|
||||
def delete_magic(management_slug: str) -> Response:
|
||||
account = store.get_account_by_management_slug(management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
hostname = str(account["hostname"])
|
||||
try:
|
||||
service.delete_account(hostname, "management-link")
|
||||
except Exception:
|
||||
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
|
||||
return HTMLResponse(
|
||||
_page(
|
||||
"DynDNS deleted",
|
||||
'<main class="shell"><section class="panel"><h1>Hostname deleted</h1><p class="note">The update URL no longer works.</p></section></main>',
|
||||
)
|
||||
)
|
||||
|
||||
@app.post("/request-domain", response_class=HTMLResponse)
|
||||
async def request_domain(request: Request) -> str:
|
||||
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())
|
||||
try:
|
||||
challenge = await run_in_threadpool(service.create_domain_challenge, _first_form_value(form, "domain"), int(user["id"]))
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
||||
return _render_magic_page(service, settings, None, None, challenge)
|
||||
|
||||
@app.post("/verify-domain", response_class=HTMLResponse)
|
||||
async def verify_domain(request: Request) -> str:
|
||||
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())
|
||||
domain = normalize_domain(_first_form_value(form, "domain"))
|
||||
claim_secret = _first_form_value(form, "claim_secret")
|
||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, int(user["id"]))
|
||||
if not found:
|
||||
return _render_magic_page(service, settings, None, "TXT record not found yet. DNS propagation can take a few minutes.", challenge)
|
||||
return _render_magic_page(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge)
|
||||
|
||||
@app.post("/accounts", response_class=HTMLResponse)
|
||||
async def self_service_account(request: Request) -> str:
|
||||
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())
|
||||
mode = _first_form_value(form, "mode")
|
||||
username = _first_form_value(form, "username") or None
|
||||
if mode == "managed":
|
||||
account = await run_in_threadpool(service.create_managed_account, username, int(user["id"]))
|
||||
else:
|
||||
claim_secret = _first_form_value(form, "claim_secret")
|
||||
account = await run_in_threadpool(
|
||||
service.create_custom_account,
|
||||
_first_form_value(form, "hostname"),
|
||||
claim_secret,
|
||||
username,
|
||||
int(user["id"]),
|
||||
)
|
||||
return _render_magic_page(service, settings, account, 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_public_page(service, settings, None, [], None, "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_public_page(service, settings, None, [], None, "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_public_page(service, settings, None, [], None, "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)
|
||||
def admin(_: None = Depends(authenticate_admin)) -> str:
|
||||
return _render_admin_page(service, settings, store.list_accounts(), store.list_invites(), None)
|
||||
|
||||
@app.post("/admin/accounts")
|
||||
async def admin_create_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
|
||||
form = _parse_form(await request.body())
|
||||
require_admin_csrf(settings, _first_form_value(form, "csrf"))
|
||||
hostname = normalize_hostname(_first_form_value(form, "hostname"), settings)
|
||||
username = _first_form_value(form, "username") or None
|
||||
if not hostname:
|
||||
raise HTTPException(status_code=400, detail="hostname is required")
|
||||
if settings.allowed_hosts and hostname not in settings.allowed_hosts:
|
||||
raise HTTPException(status_code=403, detail="hostname is not allowed")
|
||||
try:
|
||||
account = store.create_account(hostname, username)
|
||||
except sqlite3.IntegrityError as 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))
|
||||
|
||||
@app.post("/admin/accounts/delete")
|
||||
async def admin_delete_account(request: Request, _: None = Depends(authenticate_admin)) -> Response:
|
||||
form = _parse_form(await request.body())
|
||||
require_admin_csrf(settings, _first_form_value(form, "csrf"))
|
||||
hostname = _first_form_value(form, "hostname")
|
||||
if hostname:
|
||||
try:
|
||||
service.delete_account(hostname, "admin")
|
||||
except Exception:
|
||||
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
|
||||
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")
|
||||
def records(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]:
|
||||
return store.list_records()
|
||||
|
||||
@app.get("/events")
|
||||
def events(_: None = Depends(authenticate_admin)) -> list[dict[str, str | None]]:
|
||||
return store.list_update_events()
|
||||
|
||||
@app.get("/healthz", response_class=PlainTextResponse)
|
||||
def healthz() -> str:
|
||||
return "ok"
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
def favicon() -> Response:
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
|
||||
def slug_update(
|
||||
update_slug: str,
|
||||
request: Request,
|
||||
myip: str | None = Query(default=None),
|
||||
ipaddr: str | None = Query(default=None),
|
||||
myipv6: str | None = Query(default=None),
|
||||
ip6addr: str | None = Query(default=None),
|
||||
) -> str | PlainTextResponse:
|
||||
account = store.get_account_by_slug(update_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return apply_update(request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr)
|
||||
|
||||
@app.get("/nic/update", response_class=PlainTextResponse, response_model=None)
|
||||
def update(
|
||||
request: Request,
|
||||
credentials: tuple[str | None, str | None] = Depends(authenticate_update),
|
||||
hostname: str | None = Query(default=None),
|
||||
domain: str | None = Query(default=None),
|
||||
myip: str | None = Query(default=None),
|
||||
ipaddr: str | None = Query(default=None),
|
||||
myipv6: str | None = Query(default=None),
|
||||
ip6addr: str | None = Query(default=None),
|
||||
) -> str | PlainTextResponse:
|
||||
host = (hostname or domain or "").strip().lower().rstrip(".")
|
||||
if not host:
|
||||
return PlainTextResponse("nohost", status_code=400)
|
||||
if settings.allowed_hosts and host not in settings.allowed_hosts:
|
||||
return PlainTextResponse("nohost", status_code=403)
|
||||
supplied_user, supplied_token = credentials
|
||||
global_secret_ok = settings.shared_secret and supplied_token and secrets.compare_digest(supplied_token, settings.shared_secret)
|
||||
if not global_secret_ok and not store.verify_account(host, supplied_user, supplied_token):
|
||||
return PlainTextResponse("badauth", status_code=401)
|
||||
return apply_update(request, host, myip, ipaddr, myipv6, ip6addr)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _parse_form(body: bytes) -> dict[str, list[str]]:
|
||||
return urllib.parse.parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
||||
|
||||
|
||||
def _first_form_value(form: dict[str, list[str]], key: str) -> str:
|
||||
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:
|
||||
suffix = html.escape(settings.hostname_suffix or "your DynDNS domain")
|
||||
created_html = _created_account_panel(service, created_account)
|
||||
challenge_html = _challenge_panel(service, challenge, message)
|
||||
message_html = f'<section class="panel warning-panel"><p>{html.escape(message)}</p></section>' if message and not challenge else ""
|
||||
managed_html = ""
|
||||
if settings.hostname_suffix:
|
||||
managed_html = """
|
||||
<section class="panel">
|
||||
<h2>Free DynDNS hostname</h2>
|
||||
<form method="post" action="/magic" class="account-form">
|
||||
<label>Benutzername
|
||||
<input name="username" placeholder="optional">
|
||||
</label>
|
||||
<button type="submit">Generate secure URLs</button>
|
||||
</form>
|
||||
<p class="note">No account needed. You will receive a random hostname, a router update URL, and a separate magic management link. Keep the management link private.</p>
|
||||
</section>
|
||||
"""
|
||||
|
||||
return _page(
|
||||
"DynDNS",
|
||||
f"""
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">FRITZ!Box compatible</p>
|
||||
<h1>Free DynDNS for {suffix}</h1>
|
||||
<p class="lead">Generate cryptographically random URLs, paste the update URL into your router, and keep the management link somewhere safe.</p>
|
||||
</div>
|
||||
<a class="button secondary" href="/admin">Admin</a>
|
||||
</section>
|
||||
{message_html}
|
||||
{created_html}
|
||||
{managed_html}
|
||||
{challenge_html}
|
||||
</main>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def _render_management_page(service: DdnsService, settings: DdnsSettings, management_slug: str, account: dict[str, str | int | None]) -> str:
|
||||
account_for_url = {
|
||||
"hostname": str(account["hostname"]),
|
||||
"username": str(account["username"]),
|
||||
"password": "",
|
||||
"update_slug": str(account["update_slug"]),
|
||||
"management_slug": management_slug,
|
||||
}
|
||||
update_url = html.escape(service.fritz_update_url(account_for_url))
|
||||
hostname = html.escape(str(account["hostname"]))
|
||||
username = html.escape(str(account["username"]))
|
||||
ipv4 = html.escape(str(account.get("ipv4") or "-"))
|
||||
ipv6 = html.escape(str(account.get("ipv6") or "-"))
|
||||
updated = html.escape(str(account.get("updated_at") or "Never"))
|
||||
return _page(
|
||||
"DynDNS management",
|
||||
f"""
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Magic management</p>
|
||||
<h1>{hostname}</h1>
|
||||
<p class="lead">Anyone with this link can manage this hostname. Keep it private.</p>
|
||||
</div>
|
||||
<a class="button secondary" href="/">Home</a>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>FRITZ!Box fields</h2>
|
||||
<div class="fritz-form">
|
||||
<label>Update-URL:<input readonly value="{update_url}"></label>
|
||||
<label>Domainnamen:<input readonly value="{hostname}"></label>
|
||||
<label>Benutzername:<input readonly value="{username}"></label>
|
||||
<label>Kennwort:<input readonly value="unchanged; only shown when generated"></label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Current IP</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>IPv4</th><td>{ipv4}</td></tr>
|
||||
<tr><th>IPv6</th><td>{ipv6}</td></tr>
|
||||
<tr><th>Updated</th><td>{updated}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/m/{html.escape(management_slug)}/delete" class="inline-form">
|
||||
<button type="submit">Delete hostname</button>
|
||||
</form>
|
||||
</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:
|
||||
suffix = html.escape(settings.hostname_suffix or "your domain")
|
||||
message_html = f'<section class="panel warning-panel"><p>{html.escape(message)}</p></section>' if message else ""
|
||||
if not user:
|
||||
invite_field = """
|
||||
<label>Invite code
|
||||
<input name="invite" autocomplete="off" required>
|
||||
</label>
|
||||
""" if settings.require_invite else ""
|
||||
return _page(
|
||||
"DynDNS",
|
||||
f"""
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">FRITZ!Box compatible</p>
|
||||
<h1>Managed DynDNS for {suffix}</h1>
|
||||
<p class="lead">Create a secure update URL for your FRITZ!Box or router. Sign in to generate provider hostnames or verify your own domain with DNS.</p>
|
||||
</div>
|
||||
<a class="button" href="/admin">Admin</a>
|
||||
</section>
|
||||
{message_html}
|
||||
<section class="panel auth-grid">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
""",
|
||||
)
|
||||
return _render_magic_page(service, settings, created_account, message, challenge)
|
||||
|
||||
|
||||
def _challenge_panel(service: DdnsService, challenge: dict[str, str | None] | None, message: str | None) -> str:
|
||||
challenge_html = ""
|
||||
claim_secret = ""
|
||||
if challenge:
|
||||
domain = html.escape(str(challenge["domain"]))
|
||||
token = html.escape(str(challenge["token"]))
|
||||
claim_secret = html.escape(str(challenge.get("claim_secret") or ""))
|
||||
verification_name = html.escape(service.verification_name(str(challenge["domain"])))
|
||||
status = html.escape(message or "Add this TXT record, then verify.")
|
||||
challenge_html = f"""
|
||||
<div class="dns-challenge">
|
||||
<p class="note">{status}</p>
|
||||
<div class="fritz-form">
|
||||
<label>TXT name<input readonly value="{verification_name}"></label>
|
||||
<label>TXT value<input readonly value="{token}"></label>
|
||||
</div>
|
||||
<form method="post" action="/verify-domain" class="inline-form">
|
||||
<input type="hidden" name="domain" value="{domain}">
|
||||
<input type="hidden" name="claim_secret" value="{claim_secret}">
|
||||
<button type="submit">Verify DNS</button>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<section class="panel">
|
||||
<h2>Custom domain</h2>
|
||||
<form method="post" action="/request-domain" class="account-form">
|
||||
<label>Domain to verify
|
||||
<input name="domain" placeholder="example.com" required>
|
||||
</label>
|
||||
<button type="submit">Create TXT challenge</button>
|
||||
</form>
|
||||
{challenge_html}
|
||||
<form method="post" action="/accounts" class="account-form stacked">
|
||||
<input type="hidden" name="mode" value="custom">
|
||||
<input type="hidden" name="claim_secret" value="{claim_secret}">
|
||||
<label>Verified hostname
|
||||
<input name="hostname" placeholder="home.example.com" required>
|
||||
</label>
|
||||
<label>Benutzername
|
||||
<input name="username" placeholder="optional">
|
||||
</label>
|
||||
<button type="submit">Generate credentials</button>
|
||||
</form>
|
||||
<p class="note">The hostname must be inside a domain that has already passed TXT verification.</p>
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def _created_account_panel(service: DdnsService, created_account: dict[str, str] | None) -> str:
|
||||
if not created_account:
|
||||
return ""
|
||||
hostname = html.escape(created_account["hostname"])
|
||||
username = html.escape(created_account["username"])
|
||||
password = html.escape(created_account["password"])
|
||||
update_url = html.escape(service.fritz_update_url(created_account))
|
||||
management_url = html.escape(service.magic_management_url(created_account))
|
||||
return f"""
|
||||
<section class="panel success-panel">
|
||||
<div>
|
||||
<p class="eyebrow">Account generated</p>
|
||||
<h2>{hostname}</h2>
|
||||
</div>
|
||||
<div class="fritz-form">
|
||||
<label>Update-URL:<input readonly value="{update_url}"></label>
|
||||
<label>Domainnamen:<input readonly value="{hostname}"></label>
|
||||
<label>Benutzername:<input readonly value="{username}"></label>
|
||||
<label>Kennwort:<input readonly value="{password}"></label>
|
||||
<label>Magic management link:<input readonly value="{management_url}"></label>
|
||||
</div>
|
||||
<p class="note">Save this now. The password and management link are only shown on this screen. The Update-URL and management link are separate cryptographically random bearer URLs.</p>
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def _render_admin_page(
|
||||
service: DdnsService,
|
||||
settings: DdnsSettings,
|
||||
accounts: list[dict[str, str | int | None]],
|
||||
invites: list[dict[str, str | None]],
|
||||
created_account: dict[str, str] | None,
|
||||
) -> str:
|
||||
csrf = html.escape(admin_csrf_token(settings))
|
||||
rows = "\n".join(_account_row(account, show_owner=True, csrf=csrf) for account in accounts) or """
|
||||
<tr><td colspan="7" 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 = (
|
||||
f"Short names are expanded under {html.escape(settings.hostname_suffix)}."
|
||||
if settings.hostname_suffix
|
||||
else "Use a full hostname, for example home.example.net."
|
||||
)
|
||||
return _page(
|
||||
"DynDNS Admin",
|
||||
f"""
|
||||
<main class="shell">
|
||||
<section class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">DynDNS aktiv</p>
|
||||
<h1>FRITZ!Box accounts</h1>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="button secondary" href="/records">JSON records</a>
|
||||
<a class="button secondary" href="/events">Update events</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{_created_account_panel(service, created_account)}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Invites</h2>
|
||||
<form method="post" action="/admin/invites" class="inline-form">
|
||||
<input type="hidden" name="csrf" value="{csrf}">
|
||||
<button type="submit">Create invite</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Code</th><th>Created</th><th>Used</th></tr></thead>
|
||||
<tbody>{invite_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Generate account</h2>
|
||||
<p class="intro">Geben Sie die Anmeldedaten für Ihren DynDNS-Anbieter an. Generated accounts produce the FRITZ!Box fields: Update-URL, Domainnamen, Benutzername, Kennwort.</p>
|
||||
<form method="post" action="/admin/accounts" class="account-form">
|
||||
<input type="hidden" name="csrf" value="{csrf}">
|
||||
<label>Domainnamen
|
||||
<input name="hostname" placeholder="home.example.net" required>
|
||||
</label>
|
||||
<label>Benutzername
|
||||
<input name="username" placeholder="auto-generated">
|
||||
</label>
|
||||
<button type="submit">Generate</button>
|
||||
</form>
|
||||
<p class="note">{suffix_help}</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Accounts</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Domain</th><th>User</th><th>Owner</th><th>IPv4</th><th>IPv6</th><th>Updated</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def _account_row(account: dict[str, str | int | None], show_owner: bool, csrf: str = "") -> str:
|
||||
hostname = html.escape(str(account["hostname"]))
|
||||
username = html.escape(str(account["username"]))
|
||||
ipv4 = html.escape(str(account.get("ipv4") or "-"))
|
||||
ipv6 = html.escape(str(account.get("ipv6") or "-"))
|
||||
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"""
|
||||
<tr>
|
||||
<td>{hostname}</td><td>{username}</td>{owner}<td>{ipv4}</td><td>{ipv6}</td><td>{updated}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/accounts/delete">
|
||||
<input type="hidden" name="csrf" value="{html.escape(csrf)}">
|
||||
<input type="hidden" name="hostname" value="{hostname}">
|
||||
<button class="icon-button" title="Delete account" aria-label="Delete account">x</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
|
||||
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:
|
||||
return f"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--text: #18202a;
|
||||
--muted: #667085;
|
||||
--line: #d9dee7;
|
||||
--panel: #ffffff;
|
||||
--accent: #1769aa;
|
||||
--accent-strong: #0f548b;
|
||||
--good: #e9f7ef;
|
||||
--good-line: #9fd6b2;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}}
|
||||
.shell {{ width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0; }}
|
||||
.hero, .topbar {{ display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; padding: 28px 0; }}
|
||||
.button-row {{ display: flex; gap: 10px; flex-wrap: wrap; }}
|
||||
h1, h2, p {{ margin: 0; }}
|
||||
h1 {{ font-size: 34px; line-height: 1.15; font-weight: 700; letter-spacing: 0; }}
|
||||
h2 {{ font-size: 19px; line-height: 1.3; margin-bottom: 18px; }}
|
||||
.eyebrow {{ margin-bottom: 8px; color: var(--accent); font-size: 13px; font-weight: 700; text-transform: uppercase; }}
|
||||
.lead {{ max-width: 620px; margin-top: 12px; color: var(--muted); font-size: 17px; line-height: 1.5; }}
|
||||
.intro {{ margin: -6px 0 18px; color: var(--muted); font-size: 14px; line-height: 1.5; }}
|
||||
.panel {{ background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 22px; margin: 18px 0; }}
|
||||
.success-panel {{ background: var(--good); border-color: var(--good-line); }}
|
||||
.warning-panel {{ background: #fff8e6; border-color: #f0cc74; }}
|
||||
.account-form, .fritz-form {{ display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, 260px) auto; gap: 14px; align-items: end; }}
|
||||
.auth-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }}
|
||||
.stack-form {{ display: grid; gap: 14px; }}
|
||||
.inline-form {{ margin-top: 14px; }}
|
||||
.fritz-form {{ grid-template-columns: 1fr; margin-top: 18px; }}
|
||||
label {{ display: grid; gap: 7px; color: var(--muted); font-size: 13px; font-weight: 650; }}
|
||||
input {{ width: 100%; min-height: 42px; border: 1px solid var(--line); border-radius: 6px; padding: 9px 11px; color: var(--text); font: inherit; background: #fff; }}
|
||||
input[readonly] {{ background: #f9fafb; }}
|
||||
button, .button {{ min-height: 42px; border: 0; border-radius: 6px; padding: 0 16px; display: inline-flex; align-items: center; justify-content: center; background: var(--accent); color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }}
|
||||
button:hover, .button:hover {{ background: var(--accent-strong); }}
|
||||
.secondary {{ background: #344054; }}
|
||||
.note {{ margin-top: 12px; color: var(--muted); font-size: 13px; line-height: 1.45; }}
|
||||
.table-wrap {{ overflow-x: auto; }}
|
||||
table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
|
||||
th, td {{ border-bottom: 1px solid var(--line); padding: 12px 10px; text-align: left; white-space: nowrap; }}
|
||||
th {{ color: var(--muted); font-size: 12px; text-transform: uppercase; }}
|
||||
.empty {{ color: var(--muted); text-align: center; padding: 28px; }}
|
||||
.icon-button {{ width: 32px; min-height: 32px; padding: 0; background: #eef1f5; color: #344054; }}
|
||||
.icon-button:hover {{ background: #dce3ec; }}
|
||||
@media (max-width: 760px) {{
|
||||
.hero, .topbar {{ align-items: flex-start; flex-direction: column; }}
|
||||
.account-form, .auth-grid {{ grid-template-columns: 1fr; }}
|
||||
h1 {{ font-size: 28px; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>{body}</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -0,0 +1,260 @@
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from .ddns_models import DdnsSettings
|
||||
from .ddns_security import verify_session_cookie
|
||||
from .ddns_service import DdnsService, normalize_domain
|
||||
from .ddns_store import DdnsStore
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str = Field(examples=["not found"])
|
||||
|
||||
|
||||
class MagicHostnameRequest(BaseModel):
|
||||
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
|
||||
|
||||
|
||||
class HostnameCredentials(BaseModel):
|
||||
hostname: str
|
||||
username: str
|
||||
password: str
|
||||
update_url: str
|
||||
management_url: str
|
||||
|
||||
|
||||
class ManagedRecord(BaseModel):
|
||||
hostname: str
|
||||
username: str
|
||||
update_url: str
|
||||
ipv4: str | None = None
|
||||
ipv6: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class DomainChallengeRequest(BaseModel):
|
||||
domain: str = Field(examples=["example.net"])
|
||||
|
||||
|
||||
class DomainChallengeResponse(BaseModel):
|
||||
domain: str
|
||||
txt_name: str
|
||||
txt_value: str
|
||||
claim_secret: str
|
||||
|
||||
|
||||
class VerifyDomainRequest(BaseModel):
|
||||
domain: str = Field(examples=["example.net"])
|
||||
claim_secret: str
|
||||
|
||||
|
||||
class VerifyDomainResponse(BaseModel):
|
||||
domain: str
|
||||
verified: bool
|
||||
message: str
|
||||
claim_secret: str
|
||||
|
||||
|
||||
class CustomHostnameRequest(BaseModel):
|
||||
hostname: str = Field(examples=["home.example.net"])
|
||||
claim_secret: str
|
||||
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
|
||||
|
||||
|
||||
class RouterUpdateResponse(BaseModel):
|
||||
status: str = Field(examples=["good"])
|
||||
hostname: str
|
||||
ip: str
|
||||
|
||||
|
||||
def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsService) -> APIRouter:
|
||||
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
|
||||
|
||||
def require_user(request: Request) -> dict[str, str | int]:
|
||||
user = optional_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
return user
|
||||
|
||||
user_dependency = Depends(require_user)
|
||||
|
||||
@router.post(
|
||||
"/hostnames/magic",
|
||||
response_model=HostnameCredentials,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Generate a provider-owned DynDNS hostname",
|
||||
tags=["hostnames"],
|
||||
responses={500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_magic_hostname(payload: MagicHostnameRequest) -> HostnameCredentials:
|
||||
account = await run_in_threadpool(service.create_managed_account, payload.username, None)
|
||||
return _credentials_response(service, account)
|
||||
|
||||
@router.get(
|
||||
"/management/{management_slug}",
|
||||
response_model=ManagedRecord,
|
||||
summary="Read a hostname by magic management link",
|
||||
tags=["management"],
|
||||
responses={404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def read_managed_hostname(management_slug: str) -> ManagedRecord:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _managed_record(service, account)
|
||||
|
||||
@router.delete(
|
||||
"/management/{management_slug}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a hostname by magic management link",
|
||||
tags=["management"],
|
||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def delete_managed_hostname(management_slug: str) -> Response:
|
||||
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
hostname = str(account["hostname"])
|
||||
try:
|
||||
await run_in_threadpool(service.delete_account, hostname, "api")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail="DNS delete failed; hostname was not deleted") from exc
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@router.post(
|
||||
"/domains/challenges",
|
||||
response_model=DomainChallengeResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a DNS TXT domain verification challenge",
|
||||
tags=["domains"],
|
||||
responses={400: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_domain_challenge(
|
||||
payload: DomainChallengeRequest,
|
||||
user: dict[str, str | int] = user_dependency,
|
||||
) -> DomainChallengeResponse:
|
||||
domain = normalize_domain(payload.domain)
|
||||
try:
|
||||
challenge = await run_in_threadpool(service.create_domain_challenge, domain, int(user["id"]))
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="domain is already verified") from exc
|
||||
return DomainChallengeResponse(
|
||||
domain=domain,
|
||||
txt_name=service.verification_name(domain),
|
||||
txt_value=challenge["token"],
|
||||
claim_secret=challenge["claim_secret"],
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/domains/verify",
|
||||
response_model=VerifyDomainResponse,
|
||||
summary="Verify a domain TXT challenge",
|
||||
tags=["domains"],
|
||||
responses={404: {"model": ErrorResponse}},
|
||||
)
|
||||
async def verify_domain(
|
||||
payload: VerifyDomainRequest,
|
||||
user: dict[str, str | int] = user_dependency,
|
||||
) -> VerifyDomainResponse:
|
||||
domain = normalize_domain(payload.domain)
|
||||
found, challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret, int(user["id"]))
|
||||
if not found:
|
||||
return VerifyDomainResponse(
|
||||
domain=domain,
|
||||
verified=False,
|
||||
message="TXT record not found yet",
|
||||
claim_secret=payload.claim_secret,
|
||||
)
|
||||
return VerifyDomainResponse(
|
||||
domain=domain,
|
||||
verified=True,
|
||||
message="domain verified",
|
||||
claim_secret=payload.claim_secret,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/hostnames/custom",
|
||||
response_model=HostnameCredentials,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Generate credentials for a verified custom hostname",
|
||||
tags=["hostnames"],
|
||||
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
|
||||
)
|
||||
async def create_custom_hostname(
|
||||
payload: CustomHostnameRequest,
|
||||
user: dict[str, str | int] = user_dependency,
|
||||
) -> HostnameCredentials:
|
||||
account = await run_in_threadpool(
|
||||
service.create_custom_account,
|
||||
payload.hostname,
|
||||
payload.claim_secret,
|
||||
payload.username,
|
||||
int(user["id"]),
|
||||
)
|
||||
return _credentials_response(service, account)
|
||||
|
||||
@router.get(
|
||||
"/updates/{update_slug}",
|
||||
response_model=RouterUpdateResponse,
|
||||
summary="Router-compatible update endpoint",
|
||||
tags=["updates"],
|
||||
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||||
)
|
||||
async def update_hostname(
|
||||
update_slug: str,
|
||||
request: Request,
|
||||
myip: str | None = None,
|
||||
ipaddr: str | None = None,
|
||||
myipv6: str | None = None,
|
||||
ip6addr: str | None = None,
|
||||
) -> RouterUpdateResponse:
|
||||
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
|
||||
if not account or account["disabled"]:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
hostname = str(account["hostname"])
|
||||
try:
|
||||
outcome = await run_in_threadpool(
|
||||
service.apply_update,
|
||||
request,
|
||||
hostname,
|
||||
myip,
|
||||
ipaddr,
|
||||
myipv6,
|
||||
ip6addr,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
|
||||
result = outcome.result
|
||||
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _credentials_response(service: DdnsService, account: dict[str, str]) -> HostnameCredentials:
|
||||
return HostnameCredentials(
|
||||
hostname=account["hostname"],
|
||||
username=account["username"],
|
||||
password=account["password"],
|
||||
update_url=service.fritz_update_url(account),
|
||||
management_url=service.magic_management_url(account),
|
||||
)
|
||||
|
||||
|
||||
def _managed_record(service: DdnsService, account: dict[str, str | int | None]) -> ManagedRecord:
|
||||
account_for_url = {"update_slug": str(account["update_slug"])}
|
||||
return ManagedRecord(
|
||||
hostname=str(account["hostname"]),
|
||||
username=str(account["username"]),
|
||||
update_url=service.fritz_update_url(account_for_url),
|
||||
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
|
||||
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
|
||||
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from .ddns_models import DdnsSettings, UpdateResult
|
||||
|
||||
|
||||
def publish_dns(result: UpdateResult, settings: DdnsSettings) -> None:
|
||||
provider = settings.dns_provider.strip().lower()
|
||||
if provider == "cloudflare":
|
||||
publish_cloudflare(result, settings)
|
||||
return
|
||||
if settings.rfc2136_server or provider == "rfc2136":
|
||||
publish_rfc2136(result, settings)
|
||||
return
|
||||
if settings.require_dns_provider:
|
||||
raise RuntimeError("DNS provider is required but not configured")
|
||||
|
||||
|
||||
def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
|
||||
provider = settings.dns_provider.strip().lower()
|
||||
if provider == "cloudflare":
|
||||
delete_cloudflare_records(hostname, settings)
|
||||
return
|
||||
if settings.rfc2136_server or provider == "rfc2136":
|
||||
delete_rfc2136_records(hostname, settings)
|
||||
return
|
||||
if settings.require_dns_provider:
|
||||
raise RuntimeError("DNS provider is required but not configured")
|
||||
|
||||
|
||||
def dns_backend_configured(settings: DdnsSettings) -> bool:
|
||||
provider = settings.dns_provider.strip().lower()
|
||||
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
|
||||
|
||||
|
||||
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
|
||||
if not settings.rfc2136_server:
|
||||
return
|
||||
|
||||
import dns.query
|
||||
import dns.tsigkeyring
|
||||
import dns.update
|
||||
|
||||
keyring = None
|
||||
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
|
||||
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
|
||||
|
||||
zone = settings.rfc2136_zone or ".".join(result.hostname.split(".")[1:])
|
||||
update = dns.update.Update(zone, keyring=keyring)
|
||||
relative = result.hostname.removesuffix("." + zone).rstrip(".") or "@"
|
||||
if result.update_ipv4 and result.ipv4:
|
||||
update.replace(relative, settings.ttl, "A", result.ipv4)
|
||||
elif result.update_ipv4:
|
||||
update.delete(relative, "A")
|
||||
if result.update_ipv6 and result.ipv6:
|
||||
update.replace(relative, settings.ttl, "AAAA", result.ipv6)
|
||||
elif result.update_ipv6:
|
||||
update.delete(relative, "AAAA")
|
||||
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
|
||||
rcode = response.rcode()
|
||||
if rcode != 0:
|
||||
import dns.rcode
|
||||
|
||||
raise RuntimeError(f"RFC2136 update failed: {dns.rcode.to_text(rcode)}")
|
||||
|
||||
|
||||
def delete_rfc2136_records(hostname: str, settings: DdnsSettings) -> None:
|
||||
if not settings.rfc2136_server:
|
||||
return
|
||||
|
||||
import dns.query
|
||||
import dns.tsigkeyring
|
||||
import dns.update
|
||||
|
||||
keyring = None
|
||||
if settings.rfc2136_key_name and settings.rfc2136_key_secret:
|
||||
keyring = dns.tsigkeyring.from_text({settings.rfc2136_key_name: settings.rfc2136_key_secret})
|
||||
|
||||
zone = settings.rfc2136_zone or ".".join(hostname.split(".")[1:])
|
||||
update = dns.update.Update(zone, keyring=keyring)
|
||||
relative = hostname.removesuffix("." + zone).rstrip(".") or "@"
|
||||
update.delete(relative, "A")
|
||||
update.delete(relative, "AAAA")
|
||||
dns.query.tcp(update, settings.rfc2136_server, timeout=5)
|
||||
|
||||
|
||||
def publish_cloudflare(result: UpdateResult, settings: DdnsSettings) -> None:
|
||||
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
|
||||
raise RuntimeError("DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required")
|
||||
if result.update_ipv4:
|
||||
_cloudflare_upsert_record(settings, result.hostname, "A", result.ipv4)
|
||||
if result.update_ipv6:
|
||||
_cloudflare_upsert_record(settings, result.hostname, "AAAA", result.ipv6)
|
||||
|
||||
|
||||
def delete_cloudflare_records(hostname: str, settings: DdnsSettings) -> None:
|
||||
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
|
||||
raise RuntimeError("DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required")
|
||||
for record_type in ("A", "AAAA"):
|
||||
record = _cloudflare_find_record(settings, hostname, record_type)
|
||||
if record:
|
||||
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
|
||||
|
||||
|
||||
def _cloudflare_upsert_record(settings: DdnsSettings, hostname: str, record_type: str, value: str | None) -> None:
|
||||
record = _cloudflare_find_record(settings, hostname, record_type)
|
||||
if value is None:
|
||||
if record:
|
||||
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="DELETE")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"type": record_type,
|
||||
"name": hostname,
|
||||
"content": value,
|
||||
"ttl": settings.ttl,
|
||||
"proxied": False,
|
||||
}
|
||||
if record:
|
||||
_cloudflare_request(settings, f"/dns_records/{record['id']}", method="PATCH", payload=payload)
|
||||
else:
|
||||
_cloudflare_request(settings, "/dns_records", method="POST", payload=payload)
|
||||
|
||||
|
||||
def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, str] | None:
|
||||
query = urllib.parse.urlencode({"type": record_type, "name": hostname})
|
||||
response = _cloudflare_request(settings, f"/dns_records?{query}")
|
||||
records = response.get("result", [])
|
||||
return records[0] if records else None
|
||||
|
||||
|
||||
def _cloudflare_request(
|
||||
settings: DdnsSettings,
|
||||
path: str,
|
||||
method: str = "GET",
|
||||
payload: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
url = f"https://api.cloudflare.com/client/v4/zones/{settings.cloudflare_zone_id}{path}"
|
||||
body = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
request = urllib.request.Request(url, data=body, method=method)
|
||||
request.add_header("Authorization", f"Bearer {settings.cloudflare_api_token}")
|
||||
request.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
parsed = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Cloudflare API {exc.code}: {detail}") from exc
|
||||
if not parsed.get("success", False):
|
||||
raise RuntimeError(f"Cloudflare API error: {parsed}")
|
||||
return parsed
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class DdnsSettings(BaseModel):
|
||||
database_path: Path = Field(default=Path("ddns.sqlite3"))
|
||||
shared_secret: str = Field(default="")
|
||||
admin_password: str = Field(default="")
|
||||
allowed_hosts: set[str] = Field(default_factory=set)
|
||||
public_base_url: str = Field(default="http://localhost:8080")
|
||||
hostname_suffix: str = Field(default="")
|
||||
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)
|
||||
trusted_proxy_ips: set[str] = Field(default_factory=set)
|
||||
rfc2136_server: str | None = None
|
||||
rfc2136_zone: str | None = None
|
||||
rfc2136_key_name: str | None = None
|
||||
rfc2136_key_secret: str | None = None
|
||||
dns_provider: str = Field(default="")
|
||||
dns_zones: set[str] = Field(default_factory=set)
|
||||
require_dns_provider: bool = Field(default=False)
|
||||
cloudflare_api_token: str = Field(default="")
|
||||
cloudflare_zone_id: str = Field(default="")
|
||||
cleanup_challenge_hours: int = Field(default=72, ge=1, le=24 * 365)
|
||||
ttl: int = Field(default=60, ge=60, le=86400)
|
||||
|
||||
@field_validator("public_base_url")
|
||||
@classmethod
|
||||
def validate_public_base_url(cls, value: str) -> str:
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise ValueError("public_base_url must start with http:// or https://")
|
||||
return value.rstrip("/")
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> DdnsSettings:
|
||||
allowed = {
|
||||
item.strip().lower()
|
||||
for item in os.getenv("DDNS_ALLOWED_HOSTS", "").split(",")
|
||||
if item.strip()
|
||||
}
|
||||
trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS")
|
||||
dns_zones = _csv_set("DDNS_DNS_ZONES")
|
||||
return cls(
|
||||
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
|
||||
shared_secret=os.getenv("DDNS_SHARED_SECRET", ""),
|
||||
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", os.getenv("DDNS_SHARED_SECRET", "")),
|
||||
allowed_hosts=allowed,
|
||||
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
|
||||
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),
|
||||
trusted_proxy_ips=trusted_proxy_ips,
|
||||
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
|
||||
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
|
||||
rfc2136_key_name=os.getenv("DDNS_RFC2136_KEY_NAME") or None,
|
||||
rfc2136_key_secret=os.getenv("DDNS_RFC2136_KEY_SECRET") or None,
|
||||
dns_provider=os.getenv("DDNS_DNS_PROVIDER", ""),
|
||||
dns_zones=dns_zones,
|
||||
require_dns_provider=os.getenv("DDNS_REQUIRE_DNS_PROVIDER", "0").strip().lower() not in {"0", "false", "no"},
|
||||
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
|
||||
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
|
||||
cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72),
|
||||
ttl=_env_int("DDNS_TTL", 60),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UpdateResult:
|
||||
hostname: str
|
||||
ipv4: str | None
|
||||
ipv6: str | None
|
||||
changed: bool
|
||||
update_ipv4: bool = True
|
||||
update_ipv6: bool = True
|
||||
|
||||
@property
|
||||
def dyndns_response(self) -> str:
|
||||
ip = self.ipv4 or self.ipv6 or "0.0.0.0"
|
||||
return f"{'good' if self.changed else 'nochg'} {ip}"
|
||||
|
||||
|
||||
def _csv_set(name: str) -> set[str]:
|
||||
return {item.strip().lower() for item in os.getenv(name, "").split(",") if item.strip()}
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
|
||||
from .ddns_models import DdnsSettings
|
||||
|
||||
|
||||
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||
except Exception:
|
||||
return None, None
|
||||
if ":" not in raw:
|
||||
return None, None
|
||||
username, password = raw.split(":", 1)
|
||||
return username, password
|
||||
|
||||
|
||||
def hash_secret(token: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), salt.encode("ascii"), 200_000)
|
||||
return f"pbkdf2_sha256${salt}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_secret(token: str, stored: str) -> bool:
|
||||
try:
|
||||
algorithm, salt, expected = stored.split("$", 2)
|
||||
except ValueError:
|
||||
return False
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), salt.encode("ascii"), 200_000)
|
||||
return hmac.compare_digest(digest.hex(), expected)
|
||||
|
||||
|
||||
def hash_lookup_token(token: str) -> str:
|
||||
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:
|
||||
secret = settings.admin_password or settings.shared_secret or settings.session_secret
|
||||
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
return hmac.new(secret.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def require_admin_csrf(settings: DdnsSettings, supplied: str) -> None:
|
||||
if not supplied or not hmac.compare_digest(supplied, admin_csrf_token(settings)):
|
||||
raise HTTPException(status_code=403, detail="invalid csrf token")
|
||||
|
||||
|
||||
def client_ip(request: Request, settings: DdnsSettings | None = None) -> str:
|
||||
peer = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded and settings and _trusted_proxy(peer, settings.trusted_proxy_ips):
|
||||
return forwarded.split(",", 1)[0].strip()
|
||||
return peer
|
||||
|
||||
|
||||
def is_rate_limited_path(path: str) -> bool:
|
||||
limited_paths = {
|
||||
"/magic",
|
||||
"/login",
|
||||
"/register",
|
||||
"/accounts",
|
||||
"/request-domain",
|
||||
"/verify-domain",
|
||||
"/nic/update",
|
||||
}
|
||||
return path in limited_paths or path.startswith(("/u/", "/api/v1/updates/")) or path.startswith(
|
||||
("/api/v1/hostnames/", "/api/v1/domains/")
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
if not trusted:
|
||||
return False
|
||||
try:
|
||||
peer_ip = ip_address(peer)
|
||||
except ValueError:
|
||||
return False
|
||||
for candidate in trusted:
|
||||
try:
|
||||
if "/" in candidate and peer_ip in ip_network(candidate, strict=False):
|
||||
return True
|
||||
if "/" not in candidate and peer_ip == ip_address(candidate):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns
|
||||
from .ddns_models import DdnsSettings, UpdateResult
|
||||
from .ddns_security import client_ip
|
||||
from .ddns_store import DdnsStore
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UpdateOutcome:
|
||||
result: UpdateResult
|
||||
source_ip: str
|
||||
|
||||
|
||||
class DdnsService:
|
||||
def __init__(self, settings: DdnsSettings, store: DdnsStore) -> None:
|
||||
self.settings = settings
|
||||
self.store = store
|
||||
self._locks_guard = 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]:
|
||||
if not self.settings.hostname_suffix:
|
||||
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):
|
||||
try:
|
||||
return self.store.create_account(
|
||||
self.random_managed_hostname(),
|
||||
username,
|
||||
owner_user_id=owner_user_id,
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
continue
|
||||
raise HTTPException(status_code=500, detail="could not allocate hostname")
|
||||
|
||||
def create_custom_account(
|
||||
self,
|
||||
hostname: str,
|
||||
claim_secret: str,
|
||||
username: str | None = None,
|
||||
owner_user_id: int | None = None,
|
||||
) -> dict[str, str]:
|
||||
normalized = normalize_hostname(hostname, self.settings, expand_suffix=False)
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="valid custom hostname is required")
|
||||
if not self.hostname_is_publishable(normalized):
|
||||
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):
|
||||
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:
|
||||
return self.store.create_account(normalized, username, owner_user_id=owner_user_id)
|
||||
except sqlite3.IntegrityError as 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]:
|
||||
normalized = normalize_domain(domain)
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="valid domain is required")
|
||||
existing = self.store.get_domain_challenge(normalized, owner_user_id=owner_user_id)
|
||||
if existing and existing.get("verified_at"):
|
||||
raise HTTPException(status_code=409, detail="domain is already verified")
|
||||
return self.store.create_domain_challenge(normalized, owner_user_id)
|
||||
|
||||
def verify_domain(self, domain: str, claim_secret: str, owner_user_id: int | None = None) -> tuple[bool, dict[str, str | None]]:
|
||||
normalized = normalize_domain(domain)
|
||||
if not normalized:
|
||||
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)
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=404, detail="domain challenge not found")
|
||||
found = dns_txt_contains(self.verification_name(normalized), str(challenge["token"]))
|
||||
if not found:
|
||||
challenge = dict(challenge)
|
||||
challenge["claim_secret"] = claim_secret
|
||||
return False, challenge
|
||||
verified = self.store.verify_domain_challenge(normalized, owner_user_id=owner_user_id)
|
||||
if verified is None:
|
||||
raise HTTPException(status_code=404, detail="domain challenge not found")
|
||||
verified = dict(verified)
|
||||
verified["claim_secret"] = claim_secret
|
||||
return True, verified
|
||||
|
||||
def delete_account(self, hostname: str, source: str) -> None:
|
||||
try:
|
||||
delete_dns_records(hostname, self.settings)
|
||||
except Exception as exc:
|
||||
self.store.log_update_event(hostname, None, None, "dns_delete_failed", f"{type(exc).__name__}: {exc}", source)
|
||||
raise
|
||||
self.store.delete_account(hostname)
|
||||
|
||||
def apply_update(
|
||||
self,
|
||||
request: Request,
|
||||
hostname: str,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> UpdateOutcome:
|
||||
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(request, myip, ipaddr, myipv6, ip6addr)
|
||||
source_ip = client_ip(request, self.settings)
|
||||
lock = self._lock_for(hostname)
|
||||
with lock:
|
||||
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
|
||||
if planned.changed:
|
||||
try:
|
||||
publish_dns(planned, self.settings)
|
||||
except Exception as exc:
|
||||
self.store.log_update_event(hostname, ipv4, ipv6, "dns_failed", f"{type(exc).__name__}: {exc}", source_ip)
|
||||
raise
|
||||
result = self.store.upsert(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
|
||||
self.store.log_update_event(hostname, result.ipv4, result.ipv6, "updated" if result.changed else "unchanged", "ok", source_ip)
|
||||
return UpdateOutcome(result=result, source_ip=source_ip)
|
||||
|
||||
def fritz_update_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
slug = urllib.parse.quote(account["update_slug"])
|
||||
return f"{base}/u/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
|
||||
|
||||
def magic_management_url(self, account: dict[str, str]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
slug = urllib.parse.quote(account["management_slug"])
|
||||
return f"{base}/m/{slug}"
|
||||
|
||||
def random_managed_hostname(self) -> str:
|
||||
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
|
||||
if not suffix:
|
||||
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
|
||||
return f"{secrets.token_hex(6)}.{suffix}"
|
||||
|
||||
def best_verified_parent(
|
||||
self,
|
||||
hostname: str,
|
||||
owner_user_id: int | None = None,
|
||||
claim_secret: str | None = None,
|
||||
) -> str | None:
|
||||
labels = hostname.split(".")
|
||||
for index in range(1, len(labels) - 1):
|
||||
candidate = ".".join(labels[index:])
|
||||
if self.store.is_domain_verified(candidate, owner_user_id, claim_secret):
|
||||
return candidate
|
||||
if self.store.is_domain_verified(hostname, owner_user_id, claim_secret):
|
||||
return hostname
|
||||
return None
|
||||
|
||||
def verification_name(self, domain: str) -> str:
|
||||
prefix = self.settings.verification_prefix.strip().strip(".") or "_router_dyndns-ddns"
|
||||
return f"{prefix}.{domain}"
|
||||
|
||||
def hostname_is_publishable(self, hostname: str) -> bool:
|
||||
if not dns_backend_configured(self.settings):
|
||||
return not self.settings.require_dns_provider
|
||||
zones = {
|
||||
zone.strip().lower().strip(".")
|
||||
for zone in self.settings.dns_zones
|
||||
if zone.strip()
|
||||
}
|
||||
if not zones and self.settings.rfc2136_zone:
|
||||
zones.add(self.settings.rfc2136_zone.strip().lower().strip("."))
|
||||
if not zones and self.settings.hostname_suffix:
|
||||
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)
|
||||
|
||||
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:
|
||||
with self._locks_guard:
|
||||
lock = self._locks.get(hostname)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._locks[hostname] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
ip = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if version and ip.version != version:
|
||||
return None
|
||||
return str(ip)
|
||||
|
||||
|
||||
def parse_update_ips(
|
||||
request: Request,
|
||||
myip: str | None,
|
||||
ipaddr: str | None,
|
||||
myipv6: str | None,
|
||||
ip6addr: str | None,
|
||||
) -> tuple[str | None, str | None, bool, bool]:
|
||||
raw_ipv4 = myip if myip is not None else ipaddr
|
||||
raw_ipv6 = myipv6 if myipv6 is not None else ip6addr
|
||||
update_ipv4 = raw_ipv4 is not None
|
||||
update_ipv6 = raw_ipv6 is not None
|
||||
ipv4 = normalize_ip(raw_ipv4, version=4)
|
||||
ipv6 = normalize_ip(raw_ipv6, version=6)
|
||||
if not update_ipv4 and not update_ipv6:
|
||||
peer = request.client.host if request.client else None
|
||||
ipv4 = normalize_ip(peer, version=4)
|
||||
ipv6 = normalize_ip(peer, version=6)
|
||||
update_ipv4 = ipv4 is not None
|
||||
update_ipv6 = ipv6 is not None
|
||||
return ipv4, ipv6, update_ipv4, update_ipv6
|
||||
|
||||
|
||||
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
|
||||
hostname = value.strip().lower().rstrip(".")
|
||||
suffix = settings.hostname_suffix.strip().lower().strip(".")
|
||||
if expand_suffix and suffix and "." not in hostname:
|
||||
hostname = f"{hostname}.{suffix}"
|
||||
label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
||||
if not re.fullmatch(rf"{label}(\.{label})+", hostname):
|
||||
return ""
|
||||
return hostname
|
||||
|
||||
|
||||
def normalize_domain(value: str) -> str:
|
||||
return normalize_hostname(value, DdnsSettings(), expand_suffix=False)
|
||||
|
||||
|
||||
def dns_txt_contains(name: str, expected: str) -> bool:
|
||||
try:
|
||||
import dns.resolver
|
||||
|
||||
answers = dns.resolver.resolve(name, "TXT", lifetime=5)
|
||||
except Exception:
|
||||
return False
|
||||
for answer in answers:
|
||||
for item in answer.strings:
|
||||
if item.decode("utf-8", errors="replace") == expected:
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,506 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from .ddns_models import UpdateResult
|
||||
from .ddns_security import hash_lookup_token, hash_secret, verify_secret
|
||||
|
||||
|
||||
class DdnsStore:
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
self.database_path = database_path
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.database_path)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA busy_timeout = 5000")
|
||||
return conn
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
hostname: str,
|
||||
ipv4: str | None,
|
||||
ipv6: str | None,
|
||||
update_ipv4: bool = True,
|
||||
update_ipv6: bool = True,
|
||||
) -> UpdateResult:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
||||
current_ipv4 = existing["ipv4"] if existing else None
|
||||
current_ipv6 = existing["ipv6"] if existing else None
|
||||
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
|
||||
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
|
||||
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(hostname) DO UPDATE SET
|
||||
ipv4=excluded.ipv4,
|
||||
ipv6=excluded.ipv6,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(hostname, effective_ipv4, effective_ipv6, now),
|
||||
)
|
||||
return UpdateResult(
|
||||
hostname=hostname,
|
||||
ipv4=effective_ipv4,
|
||||
ipv6=effective_ipv6,
|
||||
changed=changed,
|
||||
update_ipv4=update_ipv4,
|
||||
update_ipv6=update_ipv6,
|
||||
)
|
||||
|
||||
def preview_update(
|
||||
self,
|
||||
hostname: str,
|
||||
ipv4: str | None,
|
||||
ipv6: str | None,
|
||||
update_ipv4: bool = True,
|
||||
update_ipv6: bool = True,
|
||||
) -> UpdateResult:
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
|
||||
current_ipv4 = existing["ipv4"] if existing else None
|
||||
current_ipv6 = existing["ipv6"] if existing else None
|
||||
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
|
||||
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
|
||||
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
|
||||
return UpdateResult(
|
||||
hostname=hostname,
|
||||
ipv4=effective_ipv4,
|
||||
ipv6=effective_ipv6,
|
||||
changed=changed,
|
||||
update_ipv4=update_ipv4,
|
||||
update_ipv6=update_ipv6,
|
||||
)
|
||||
|
||||
def create_account(
|
||||
self,
|
||||
hostname: str,
|
||||
username: str | None = None,
|
||||
owner_user_id: int | None = None,
|
||||
) -> dict[str, str]:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
token = secrets.token_urlsafe(24)
|
||||
update_slug = secrets.token_urlsafe(32)
|
||||
management_slug = secrets.token_urlsafe(32)
|
||||
username = username or _default_username(hostname)
|
||||
token_hash = hash_secret(token)
|
||||
management_hash = hash_lookup_token(management_slug)
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, owner_user_id, created_at, disabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(hostname, username, token_hash, update_slug, management_hash, owner_user_id, now),
|
||||
)
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"username": username,
|
||||
"password": token,
|
||||
"update_slug": update_slug,
|
||||
"management_slug": management_slug,
|
||||
}
|
||||
|
||||
def list_accounts(self, owner_user_id: int | None = None) -> list[dict[str, str | int | None]]:
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
sql = """
|
||||
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.owner_user_id,
|
||||
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||
FROM accounts
|
||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||
"""
|
||||
params: tuple[int, ...] = ()
|
||||
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]
|
||||
|
||||
def get_account_by_slug(self, update_slug: str) -> dict[str, str | int | None] | None:
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
|
||||
WHERE update_slug = ?
|
||||
""",
|
||||
(update_slug,),
|
||||
).fetchone()
|
||||
return dict(account) if account else None
|
||||
|
||||
def get_account_by_management_slug(self, management_slug: str) -> dict[str, str | int | None] | None:
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.owner_user_id,
|
||||
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
||||
FROM accounts
|
||||
LEFT JOIN records ON records.hostname = accounts.hostname
|
||||
WHERE accounts.management_hash = ?
|
||||
""",
|
||||
(hash_lookup_token(management_slug),),
|
||||
).fetchone()
|
||||
return dict(account) if account else None
|
||||
|
||||
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool:
|
||||
if not username or not token:
|
||||
return False
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
account = conn.execute(
|
||||
"""
|
||||
SELECT token_hash, disabled FROM accounts
|
||||
WHERE hostname = ? AND username = ?
|
||||
""",
|
||||
(hostname, username),
|
||||
).fetchone()
|
||||
if not account or account["disabled"]:
|
||||
return False
|
||||
return verify_secret(token, account["token_hash"])
|
||||
|
||||
def delete_account(self, hostname: str) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM accounts 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]:
|
||||
token = f"ff-ddns-{secrets.token_urlsafe(24)}"
|
||||
claim_secret = secrets.token_urlsafe(32)
|
||||
claim_hash = hash_lookup_token(claim_secret)
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT owner_user_id, verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
|
||||
if existing and existing[1]:
|
||||
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(
|
||||
"""
|
||||
INSERT INTO domain_challenges(domain, token, claim_hash, owner_user_id, created_at, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
token=excluded.token,
|
||||
claim_hash=excluded.claim_hash,
|
||||
owner_user_id=excluded.owner_user_id,
|
||||
created_at=excluded.created_at,
|
||||
verified_at=NULL
|
||||
""",
|
||||
(domain, token, claim_hash, owner_user_id, now),
|
||||
)
|
||||
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:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
sql = "SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?"
|
||||
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:
|
||||
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))
|
||||
result = dict(challenge)
|
||||
result["verified_at"] = now
|
||||
return result
|
||||
|
||||
def get_domain_challenge(
|
||||
self,
|
||||
domain: str,
|
||||
owner_user_id: int | None = None,
|
||||
claim_secret: str | None = None,
|
||||
) -> dict[str, str | None] | None:
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
sql = "SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?"
|
||||
params: tuple[str, ...] | tuple[str, int] = (domain,)
|
||||
if owner_user_id is not None:
|
||||
sql += " AND owner_user_id = ?"
|
||||
params = (domain, owner_user_id)
|
||||
elif claim_secret is not None:
|
||||
sql += " AND claim_hash = ?"
|
||||
params = (domain, hash_lookup_token(claim_secret))
|
||||
challenge = conn.execute(sql, params).fetchone()
|
||||
return dict(challenge) if challenge else None
|
||||
|
||||
def is_domain_verified(
|
||||
self,
|
||||
domain: str,
|
||||
owner_user_id: int | None = None,
|
||||
claim_secret: str | None = None,
|
||||
) -> bool:
|
||||
with self.connect() as conn:
|
||||
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
|
||||
params: tuple[str, ...] | tuple[str, int] = (domain,)
|
||||
if owner_user_id is not None:
|
||||
sql += " AND owner_user_id = ?"
|
||||
params = (domain, owner_user_id)
|
||||
elif claim_secret is not None:
|
||||
sql += " AND claim_hash = ?"
|
||||
params = (domain, hash_lookup_token(claim_secret))
|
||||
verified_at = conn.execute(sql, params).fetchone()
|
||||
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]]:
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("SELECT hostname, ipv4, ipv6, updated_at FROM records ORDER BY hostname").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def log_update_event(
|
||||
self,
|
||||
hostname: str,
|
||||
ipv4: str | None,
|
||||
ipv6: str | None,
|
||||
status: str,
|
||||
detail: str,
|
||||
source_ip: str,
|
||||
) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()),
|
||||
)
|
||||
|
||||
def list_update_events(self, limit: int = 100) -> list[dict[str, str | None]]:
|
||||
with self.connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at
|
||||
FROM update_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def allow_rate_limit(self, key: str, limit: int) -> bool:
|
||||
now = int(time.time())
|
||||
window = now // 60
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM rate_limits WHERE window < ?", (window - 2,))
|
||||
row = conn.execute(
|
||||
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
|
||||
(key, window),
|
||||
).fetchone()
|
||||
count = int(row[0]) if row else 0
|
||||
if count >= limit:
|
||||
return False
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO rate_limits(key, window, count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(key, window) DO UPDATE SET count=count + 1
|
||||
""",
|
||||
(key, window),
|
||||
)
|
||||
return True
|
||||
|
||||
def cleanup(self, challenge_hours: int) -> None:
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=challenge_hours)
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?",
|
||||
(cutoff.isoformat(),),
|
||||
)
|
||||
|
||||
def _init_db(self) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA synchronous = NORMAL")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS records (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
ipv4 TEXT,
|
||||
ipv6 TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS update_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
ipv4 TEXT,
|
||||
ipv6 TEXT,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
source_ip TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
PRIMARY KEY(key, window)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
update_slug TEXT NOT NULL UNIQUE,
|
||||
management_hash TEXT UNIQUE,
|
||||
owner_user_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
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", "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 = ''"):
|
||||
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 = ''"):
|
||||
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (hash_lookup_token(secrets.token_urlsafe(32)), row[0]))
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS domain_challenges (
|
||||
domain TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
claim_hash TEXT,
|
||||
owner_user_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
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", "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:
|
||||
label = hostname.split(".", 1)[0]
|
||||
import re
|
||||
|
||||
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
|
||||
|
||||
|
||||
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
|
||||
columns = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||
if column not in columns:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
@@ -0,0 +1,568 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
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:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "home.example.net",
|
||||
"myip": "203.0.113.8",
|
||||
"myipv6": "2001:db8::8",
|
||||
"password": "secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.8"
|
||||
records = client.get("/records", auth=("admin", "secret")).json()
|
||||
assert records[0]["hostname"] == "home.example.net"
|
||||
assert records[0]["ipv4"] == "203.0.113.8"
|
||||
assert records[0]["ipv6"] == "2001:db8::8"
|
||||
|
||||
|
||||
def test_update_rejects_wrong_secret(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "home.example.net", "myip": "203.0.113.8", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_rejects_unlisted_host(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.8", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_generated_account_can_update_own_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "home.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.9"
|
||||
|
||||
slug_response = client.get(
|
||||
f"/u/{account['update_slug']}",
|
||||
params={"myip": "203.0.113.10"},
|
||||
)
|
||||
|
||||
assert slug_response.status_code == 200
|
||||
assert slug_response.text == "good 203.0.113.10"
|
||||
|
||||
|
||||
def test_dns_publish_failure_does_not_update_record(tmp_path: Path, monkeypatch) -> None:
|
||||
def fail_publish(result, settings):
|
||||
raise RuntimeError("dns down")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
events = store.list_update_events()
|
||||
assert events[0]["status"] == "dns_failed"
|
||||
|
||||
|
||||
def test_dns_publish_success_updates_record(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.9"
|
||||
assert len(published) == 1
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.9"
|
||||
|
||||
|
||||
def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "other.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_admin_page_shows_account_form(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", auth=("admin", "admin"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL" in response.text
|
||||
assert "Domainnamen" in response.text
|
||||
assert "Benutzername" in response.text
|
||||
assert "Kennwort" in response.text
|
||||
|
||||
|
||||
def test_admin_posts_require_csrf(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/admin/accounts",
|
||||
auth=("admin", "admin"),
|
||||
data={"hostname": "home.example.net"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", params={"password": "admin"})
|
||||
|
||||
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:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
require_invite=False,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
login_test_user(client)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert ".ddns.example.net" in response.text
|
||||
assert "/u/" in response.text
|
||||
assert "/m/" in response.text
|
||||
|
||||
|
||||
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/magic", data={"username": "alice"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "Magic management link:" in response.text
|
||||
assert ".ddns.example.net" in response.text
|
||||
|
||||
|
||||
def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
management = client.get(f"/m/{account['management_slug']}")
|
||||
assert management.status_code == 200
|
||||
assert "home.ddns.example.net" in management.text
|
||||
|
||||
deleted = client.post(f"/m/{account['management_slug']}/delete")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
assert update.status_code == 404
|
||||
|
||||
|
||||
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))
|
||||
client = TestClient(app)
|
||||
login_test_user(client)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_custom_hostname_after_dns_verification(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",
|
||||
require_invite=False,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
login_test_user(client)
|
||||
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
challenge_data = store.create_domain_challenge("example.net", owner_user_id=1)
|
||||
verify = client.post(
|
||||
"/verify-domain",
|
||||
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
"/accounts",
|
||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "home.example.net" in response.text
|
||||
assert "Update-URL:" in response.text
|
||||
|
||||
|
||||
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"))
|
||||
client = TestClient(app)
|
||||
|
||||
redoc = client.get("/redoc")
|
||||
schema = client.get("/openapi.json").json()
|
||||
|
||||
assert redoc.status_code == 200
|
||||
assert "/api/v1/hostnames/magic" in schema["paths"]
|
||||
assert "/api/v1/updates/{update_slug}" in schema["paths"]
|
||||
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
||||
|
||||
|
||||
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["hostname"].endswith(".ddns.example.net")
|
||||
assert body["update_url"].startswith("http://ddns.example.net/u/")
|
||||
assert body["management_url"].startswith("http://ddns.example.net/m/")
|
||||
|
||||
management_slug = body["management_url"].rsplit("/", 1)[1]
|
||||
managed = client.get(f"/api/v1/management/{management_slug}")
|
||||
assert managed.status_code == 200
|
||||
assert managed.json()["hostname"] == body["hostname"]
|
||||
|
||||
deleted = client.delete(f"/api/v1/management/{management_slug}")
|
||||
assert deleted.status_code == 204
|
||||
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
|
||||
|
||||
|
||||
def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
|
||||
assert len(published) == 1
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
|
||||
|
||||
|
||||
def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypatch) -> None:
|
||||
def fail_publish(result, settings):
|
||||
raise RuntimeError("dns down")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
assert store.list_update_events()[0]["status"] == "dns_failed"
|
||||
|
||||
|
||||
def test_api_custom_hostname_requires_verified_domain(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"))
|
||||
client = TestClient(app)
|
||||
login_test_user(client)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||
assert challenge.status_code == 201
|
||||
challenge_body = challenge.json()
|
||||
|
||||
verified = client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge_body["claim_secret"]},
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["verified"] is True
|
||||
|
||||
hostname = client.post(
|
||||
"/api/v1/hostnames/custom",
|
||||
json={
|
||||
"hostname": "home.example.net",
|
||||
"claim_secret": challenge_body["claim_secret"],
|
||||
"username": "router",
|
||||
},
|
||||
)
|
||||
|
||||
assert hostname.status_code == 201
|
||||
assert hostname.json()["hostname"] == "home.example.net"
|
||||
|
||||
|
||||
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post("/magic", data={"username": "alice"}, headers={"x-forwarded-for": "198.51.100.10"})
|
||||
second = client.post("/magic", data={"username": "bob"}, headers={"x-forwarded-for": "198.51.100.11"})
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 200
|
||||
record = store.list_records()[0]
|
||||
assert record["ipv4"] == "203.0.113.9"
|
||||
assert record["ipv6"] == "2001:db8::8"
|
||||
assert published[0].update_ipv4 is True
|
||||
assert published[0].update_ipv6 is False
|
||||
|
||||
|
||||
def test_verified_domain_challenge_cannot_be_overwritten(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"))
|
||||
client = TestClient(app)
|
||||
login_test_user(client)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||
verified = client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
overwrite = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||
|
||||
assert verified.status_code == 200
|
||||
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:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
dns_zones={"ddns.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
login_test_user(client)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||
client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
hostname = client.post(
|
||||
"/api/v1/hostnames/custom",
|
||||
json={"hostname": "home.example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
|
||||
assert hostname.status_code == 403
|
||||
assert hostname.json()["detail"] == "hostname is outside configured DNS publishing zones"
|
||||
|
||||
|
||||
def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> None:
|
||||
try:
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
assert "no DNS backend" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected startup failure")
|
||||
Reference in new issue
Block a user