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