Archived
Harden production launch defaults
This commit is contained in:
1 parent
8b80e0e3d2
commit
2203852170
8 files changed
+176
-30
No files matched your search
@@ -2,6 +2,7 @@ DDNS_ADMIN_PASSWORD=change-me-long-random-admin-password
|
|||||||
DDNS_PUBLIC_BASE_URL=https://ddns.example.net
|
DDNS_PUBLIC_BASE_URL=https://ddns.example.net
|
||||||
DDNS_HOSTNAME_SUFFIX=ddns.example.net
|
DDNS_HOSTNAME_SUFFIX=ddns.example.net
|
||||||
DDNS_DATABASE=/data/ddns.sqlite3
|
DDNS_DATABASE=/data/ddns.sqlite3
|
||||||
|
DDNS_TRUSTED_HOSTS=ddns.example.net
|
||||||
DDNS_DNS_PROVIDER=cloudflare
|
DDNS_DNS_PROVIDER=cloudflare
|
||||||
DDNS_DNS_ZONES=ddns.example.net
|
DDNS_DNS_ZONES=ddns.example.net
|
||||||
DDNS_REQUIRE_DNS_PROVIDER=1
|
DDNS_REQUIRE_DNS_PROVIDER=1
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ WORKDIR /app
|
|||||||
COPY pyproject.toml README.md /app/
|
COPY pyproject.toml README.md /app/
|
||||||
COPY router_dyndns /app/router_dyndns
|
COPY router_dyndns /app/router_dyndns
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
RUN adduser --system --group --home /app routerpulse \
|
||||||
|
&& mkdir -p /data \
|
||||||
|
&& chown -R routerpulse:routerpulse /app /data
|
||||||
|
USER routerpulse
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["router-dyndns", "serve", "--host", "0.0.0.0", "--port", "8080"]
|
CMD ["router-dyndns", "serve", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
@@ -102,6 +102,7 @@ For an internet-facing DynDNS service, run behind HTTPS and require a real DNS b
|
|||||||
export DDNS_PUBLIC_BASE_URL='https://ddns.example.net'
|
export DDNS_PUBLIC_BASE_URL='https://ddns.example.net'
|
||||||
export DDNS_HOSTNAME_SUFFIX='ddns.example.net'
|
export DDNS_HOSTNAME_SUFFIX='ddns.example.net'
|
||||||
export DDNS_DATABASE='/data/ddns.sqlite3'
|
export DDNS_DATABASE='/data/ddns.sqlite3'
|
||||||
|
export DDNS_TRUSTED_HOSTS='ddns.example.net'
|
||||||
export DDNS_REQUIRE_DNS_PROVIDER=1
|
export DDNS_REQUIRE_DNS_PROVIDER=1
|
||||||
export DDNS_DNS_ZONES='ddns.example.net'
|
export DDNS_DNS_ZONES='ddns.example.net'
|
||||||
export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1'
|
export DDNS_TRUSTED_PROXY_IPS='127.0.0.1,::1'
|
||||||
@@ -211,6 +212,7 @@ docker compose up -d --build
|
|||||||
- Keep `.env` out of git.
|
- Keep `.env` out of git.
|
||||||
- Use a long random value for `DDNS_ADMIN_PASSWORD`.
|
- Use a long random value for `DDNS_ADMIN_PASSWORD`.
|
||||||
- Use a least-privilege DNS API token or TSIG key.
|
- Use a least-privilege DNS API token or TSIG key.
|
||||||
|
- Set `DDNS_TRUSTED_HOSTS` to the public hostnames your reverse proxy will forward, for example `ddns.example.net`.
|
||||||
- Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers.
|
- Configure `DDNS_TRUSTED_PROXY_IPS` only for reverse proxies that strip user-supplied forwarding headers.
|
||||||
- Keep `/docs` and `/redoc` available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth.
|
- Keep `/docs` and `/redoc` available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth.
|
||||||
- Leave cleanup enabled. The app periodically removes abandoned TXT challenges and generated hostnames that were never updated by a router.
|
- Leave cleanup enabled. The app periodically removes abandoned TXT challenges and generated hostnames that were never updated by a router.
|
||||||
@@ -224,6 +226,7 @@ Security and cleanup defaults:
|
|||||||
| `DDNS_RATE_LIMIT_PER_MINUTE` | `60` | Public update/API/form requests allowed per source IP and path. |
|
| `DDNS_RATE_LIMIT_PER_MINUTE` | `60` | Public update/API/form requests allowed per source IP and path. |
|
||||||
| `DDNS_ADMIN_RATE_LIMIT_PER_MINUTE` | `20` | Admin/operator requests allowed per source IP and path. |
|
| `DDNS_ADMIN_RATE_LIMIT_PER_MINUTE` | `20` | Admin/operator requests allowed per source IP and path. |
|
||||||
| `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. |
|
| `DDNS_MAX_REQUEST_BODY_BYTES` | `16384` | Maximum accepted request body for form/API endpoints. |
|
||||||
|
| `DDNS_TRUSTED_HOSTS` | from `DDNS_PUBLIC_BASE_URL` | Host header allowlist. Add every public hostname that should serve the app. |
|
||||||
| `DDNS_CLEANUP_CHALLENGE_HOURS` | `72` | Delete unverified DNS TXT claims after this many hours. |
|
| `DDNS_CLEANUP_CHALLENGE_HOURS` | `72` | Delete unverified DNS TXT claims after this many hours. |
|
||||||
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `168` | Delete generated hostnames that never received a router update after this many hours. |
|
| `DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS` | `168` | Delete generated hostnames that never received a router update after this many hours. |
|
||||||
| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Run the in-app cleanup scheduler at this interval. |
|
| `DDNS_CLEANUP_INTERVAL_SECONDS` | `3600` | Run the in-app cleanup scheduler at this interval. |
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ddns-data:/data
|
- ddns-data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2).read()"]
|
test: ["CMD", "python", "-c", "import os, urllib.parse, urllib.request; host=urllib.parse.urlparse(os.environ['DDNS_PUBLIC_BASE_URL']).hostname; req=urllib.request.Request('http://127.0.0.1:8080/healthz', headers={'Host': host}); urllib.request.urlopen(req, timeout=2).read()"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
+60
-27
@@ -14,6 +14,7 @@ from typing import Annotated
|
|||||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, RedirectResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
from .ddns_api import create_api_router
|
from .ddns_api import create_api_router
|
||||||
@@ -24,6 +25,7 @@ from .ddns_security import (
|
|||||||
client_ip,
|
client_ip,
|
||||||
credentials_from_basic_auth,
|
credentials_from_basic_auth,
|
||||||
is_rate_limited_path,
|
is_rate_limited_path,
|
||||||
|
rate_limit_bucket,
|
||||||
require_admin_csrf,
|
require_admin_csrf,
|
||||||
)
|
)
|
||||||
from .ddns_service import DdnsService, normalize_domain, normalize_hostname
|
from .ddns_service import DdnsService, normalize_domain, normalize_hostname
|
||||||
@@ -60,6 +62,31 @@ OPENAPI_TAGS = [
|
|||||||
|
|
||||||
ASSET_DIR = Path(__file__).with_name("assets")
|
ASSET_DIR = Path(__file__).with_name("assets")
|
||||||
|
|
||||||
|
APP_JS = """
|
||||||
|
(() => {
|
||||||
|
const applyTheme = () => {
|
||||||
|
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||||
|
document.documentElement.style.colorScheme = theme;
|
||||||
|
};
|
||||||
|
applyTheme();
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme);
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener("click", async (event) => {
|
||||||
|
const button = event.target.closest("[data-copy]");
|
||||||
|
if (!button) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(button.dataset.copy || "");
|
||||||
|
const old = button.textContent;
|
||||||
|
button.textContent = "Copied";
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = old;
|
||||||
|
}, 1200);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
class RequestBodyTooLargeError(Exception):
|
class RequestBodyTooLargeError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -99,6 +126,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
settings = settings.model_copy(update={"admin_password": settings.shared_secret})
|
settings = settings.model_copy(update={"admin_password": settings.shared_secret})
|
||||||
if settings.require_dns_provider and not dns_backend_configured(settings):
|
if settings.require_dns_provider and not dns_backend_configured(settings):
|
||||||
raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
|
raise RuntimeError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
|
||||||
|
settings.validate_launch_ready()
|
||||||
|
|
||||||
store = DdnsStore(settings.database_path)
|
store = DdnsStore(settings.database_path)
|
||||||
store.cleanup(settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours)
|
store.cleanup(settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours)
|
||||||
@@ -127,6 +155,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
openapi_tags=OPENAPI_TAGS,
|
openapi_tags=OPENAPI_TAGS,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.effective_trusted_hosts)
|
||||||
app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes)
|
app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes)
|
||||||
app.include_router(create_api_router(settings, store, service))
|
app.include_router(create_api_router(settings, store, service))
|
||||||
|
|
||||||
@@ -141,7 +170,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
if is_rate_limited_path(request.url.path) or _is_admin_path(request.url.path):
|
if is_rate_limited_path(request.url.path) or _is_admin_path(request.url.path):
|
||||||
limit = settings.admin_rate_limit_per_minute if _is_admin_path(request.url.path) else settings.rate_limit_per_minute
|
limit = settings.admin_rate_limit_per_minute if _is_admin_path(request.url.path) else settings.rate_limit_per_minute
|
||||||
key = f"{client_ip(request, settings)}:{request.url.path}"
|
key = f"{client_ip(request, settings)}:{rate_limit_bucket(request.url.path)}"
|
||||||
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
|
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return PlainTextResponse("rate limit exceeded", status_code=429)
|
return PlainTextResponse("rate limit exceeded", status_code=429)
|
||||||
@@ -154,9 +183,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["Referrer-Policy"] = "same-origin"
|
response.headers["Referrer-Policy"] = "same-origin"
|
||||||
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = _content_security_policy(request.url.path)
|
||||||
"default-src 'self'; script-src 'unsafe-inline' 'self'; style-src 'unsafe-inline' 'self' https://cdn.jsdelivr.net; form-action 'self'; frame-ancestors 'none'"
|
|
||||||
)
|
|
||||||
if settings.public_base_url.startswith("https://"):
|
if settings.public_base_url.startswith("https://"):
|
||||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
return response
|
return response
|
||||||
@@ -441,6 +468,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
|
|||||||
def logo_png() -> FileResponse:
|
def logo_png() -> FileResponse:
|
||||||
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png")
|
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png")
|
||||||
|
|
||||||
|
@app.get("/app.js", include_in_schema=False)
|
||||||
|
def app_js() -> Response:
|
||||||
|
return Response(APP_JS, media_type="application/javascript")
|
||||||
|
|
||||||
@app.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
|
@app.get("/u/{update_slug}", response_class=PlainTextResponse, response_model=None)
|
||||||
def slug_update(
|
def slug_update(
|
||||||
update_slug: str,
|
update_slug: str,
|
||||||
@@ -518,6 +549,30 @@ def _is_secret_response(request: Request) -> bool:
|
|||||||
return request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}
|
return request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}
|
||||||
|
|
||||||
|
|
||||||
|
def _content_security_policy(path: str) -> str:
|
||||||
|
if path in {"/docs", "/redoc", "/openapi.json"}:
|
||||||
|
return (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
|
"img-src 'self' data: https://fastapi.tiangolo.com; "
|
||||||
|
"font-src 'self' https://cdn.jsdelivr.net; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"font-src 'self' https://cdn.jsdelivr.net; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_public_home(
|
def _render_public_home(
|
||||||
service: DdnsService,
|
service: DdnsService,
|
||||||
settings: DdnsSettings,
|
settings: DdnsSettings,
|
||||||
@@ -1038,17 +1093,7 @@ def _page(title: str, body: str) -> str:
|
|||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="icon" href="/logo.svg" type="image/svg+xml">
|
<link rel="icon" href="/logo.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/logo.png">
|
<link rel="apple-touch-icon" href="/logo.png">
|
||||||
<script>
|
<script src="/app.js" defer></script>
|
||||||
(() => {{
|
|
||||||
const applyTheme = () => {{
|
|
||||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
||||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
|
||||||
document.documentElement.style.colorScheme = theme;
|
|
||||||
}};
|
|
||||||
applyTheme();
|
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme);
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||||
<style>
|
<style>
|
||||||
:root {{
|
:root {{
|
||||||
@@ -1195,18 +1240,6 @@ def _page(title: str, body: str) -> str:
|
|||||||
h2 {{ font-size: 25px; }}
|
h2 {{ font-size: 25px; }}
|
||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
|
||||||
document.addEventListener("click", async (event) => {{
|
|
||||||
const button = event.target.closest("[data-copy]");
|
|
||||||
if (!button) return;
|
|
||||||
try {{
|
|
||||||
await navigator.clipboard.writeText(button.dataset.copy || "");
|
|
||||||
const old = button.textContent;
|
|
||||||
button.textContent = "Copied";
|
|
||||||
setTimeout(() => {{ button.textContent = old; }}, 1200);
|
|
||||||
}} catch (_) {{}}
|
|
||||||
}});
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>{body}</body>
|
<body>{body}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import urllib.parse
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ class DdnsSettings(BaseModel):
|
|||||||
admin_password: str = Field(default="")
|
admin_password: str = Field(default="")
|
||||||
allowed_hosts: set[str] = Field(default_factory=set)
|
allowed_hosts: set[str] = Field(default_factory=set)
|
||||||
public_base_url: str = Field(default="http://localhost:8080")
|
public_base_url: str = Field(default="http://localhost:8080")
|
||||||
|
trusted_hosts: set[str] = Field(default_factory=set)
|
||||||
hostname_suffix: str = Field(default="")
|
hostname_suffix: str = Field(default="")
|
||||||
verification_prefix: str = Field(default="_router_dyndns-ddns")
|
verification_prefix: str = Field(default="_router_dyndns-ddns")
|
||||||
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
||||||
@@ -40,6 +42,31 @@ class DdnsSettings(BaseModel):
|
|||||||
raise ValueError("public_base_url must start with http:// or https://")
|
raise ValueError("public_base_url must start with http:// or https://")
|
||||||
return value.rstrip("/")
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
@field_validator("trusted_hosts")
|
||||||
|
@classmethod
|
||||||
|
def validate_trusted_hosts(cls, value: set[str]) -> set[str]:
|
||||||
|
return {host.strip().lower() for host in value if host.strip()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_trusted_hosts(self) -> list[str]:
|
||||||
|
hosts = set(self.trusted_hosts)
|
||||||
|
public_host = urllib.parse.urlparse(self.public_base_url).hostname
|
||||||
|
if public_host:
|
||||||
|
hosts.add(public_host.lower())
|
||||||
|
if not self.require_dns_provider:
|
||||||
|
hosts.update({"testserver", "localhost", "127.0.0.1", "::1"})
|
||||||
|
return sorted(hosts)
|
||||||
|
|
||||||
|
def validate_launch_ready(self) -> None:
|
||||||
|
if not self.require_dns_provider:
|
||||||
|
return
|
||||||
|
if len(self.admin_password) < 16:
|
||||||
|
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
|
||||||
|
if not self.hostname_suffix:
|
||||||
|
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
|
||||||
|
if not self.effective_trusted_hosts:
|
||||||
|
raise RuntimeError("At least one trusted host is required for production")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> DdnsSettings:
|
def from_env(cls) -> DdnsSettings:
|
||||||
allowed = {
|
allowed = {
|
||||||
@@ -48,6 +75,7 @@ class DdnsSettings(BaseModel):
|
|||||||
if item.strip()
|
if item.strip()
|
||||||
}
|
}
|
||||||
trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS")
|
trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS")
|
||||||
|
trusted_hosts = _csv_set("DDNS_TRUSTED_HOSTS")
|
||||||
dns_zones = _csv_set("DDNS_DNS_ZONES")
|
dns_zones = _csv_set("DDNS_DNS_ZONES")
|
||||||
return cls(
|
return cls(
|
||||||
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
|
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
|
||||||
@@ -55,6 +83,7 @@ class DdnsSettings(BaseModel):
|
|||||||
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", os.getenv("DDNS_SHARED_SECRET", "")),
|
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", os.getenv("DDNS_SHARED_SECRET", "")),
|
||||||
allowed_hosts=allowed,
|
allowed_hosts=allowed,
|
||||||
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
|
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||||
|
trusted_hosts=trusted_hosts,
|
||||||
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
|
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
|
||||||
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"),
|
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"),
|
||||||
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
|
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
|
||||||
|
|||||||
@@ -71,11 +71,18 @@ def is_rate_limited_path(path: str) -> bool:
|
|||||||
"/verify-domain",
|
"/verify-domain",
|
||||||
"/nic/update",
|
"/nic/update",
|
||||||
}
|
}
|
||||||
return path in limited_paths or path.startswith(("/u/", "/api/v1/updates/")) or path.startswith(
|
return path in limited_paths or path.startswith(("/u/", "/m/", "/d/", "/api/v1/updates/")) or path.startswith(
|
||||||
("/api/v1/hostnames/", "/api/v1/domains/")
|
("/api/v1/hostnames/", "/api/v1/domains/", "/api/v1/management/")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit_bucket(path: str) -> str:
|
||||||
|
for prefix in ("/u/", "/m/", "/d/", "/api/v1/updates/", "/api/v1/management/"):
|
||||||
|
if path.startswith(prefix):
|
||||||
|
return prefix.rstrip("/")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
|
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
|
||||||
if not trusted:
|
if not trusted:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -212,6 +212,23 @@ def test_admin_routes_are_rate_limited(tmp_path: Path) -> None:
|
|||||||
assert second.status_code == 429
|
assert second.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||||
|
app = make_app(
|
||||||
|
DdnsSettings(
|
||||||
|
database_path=tmp_path / "ddns.sqlite3",
|
||||||
|
admin_password="admin",
|
||||||
|
rate_limit_per_minute=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
first = client.get("/m/not-a-real-token")
|
||||||
|
second = client.get("/m/another-token")
|
||||||
|
|
||||||
|
assert first.status_code == 404
|
||||||
|
assert second.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
|
def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
|
||||||
settings = DdnsSettings(
|
settings = DdnsSettings(
|
||||||
database_path=tmp_path / "ddns.sqlite3",
|
database_path=tmp_path / "ddns.sqlite3",
|
||||||
@@ -385,10 +402,13 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
|||||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
docs = client.get("/docs")
|
||||||
redoc = client.get("/redoc")
|
redoc = client.get("/redoc")
|
||||||
schema = client.get("/openapi.json").json()
|
schema = client.get("/openapi.json").json()
|
||||||
|
|
||||||
|
assert docs.status_code == 200
|
||||||
assert redoc.status_code == 200
|
assert redoc.status_code == 200
|
||||||
|
assert "unsafe-inline" in docs.headers["content-security-policy"]
|
||||||
assert "/api/v1/hostnames/magic" in schema["paths"]
|
assert "/api/v1/hostnames/magic" in schema["paths"]
|
||||||
assert "/api/v1/updates/{update_slug}" in schema["paths"]
|
assert "/api/v1/updates/{update_slug}" in schema["paths"]
|
||||||
assert "/admin" not in schema["paths"]
|
assert "/admin" not in schema["paths"]
|
||||||
@@ -398,6 +418,35 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
|||||||
assert all(tag["name"] != "admin" for tag in schema["tags"])
|
assert all(tag["name"] != "admin" for tag in schema["tags"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
||||||
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert '<script src="/app.js" defer></script>' in response.text
|
||||||
|
assert "script-src 'self'" in response.headers["content-security-policy"]
|
||||||
|
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
|
||||||
|
assert client.get("/app.js").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
|
||||||
|
app = make_app(
|
||||||
|
DdnsSettings(
|
||||||
|
database_path=tmp_path / "ddns.sqlite3",
|
||||||
|
admin_password="admin",
|
||||||
|
public_base_url="http://ddns.example.net",
|
||||||
|
trusted_hosts={"ddns.example.net"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/", headers={"host": "evil.example.net"})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
|
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
|
||||||
app = make_app(
|
app = make_app(
|
||||||
DdnsSettings(
|
DdnsSettings(
|
||||||
@@ -639,6 +688,26 @@ def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> N
|
|||||||
raise AssertionError("expected startup failure")
|
raise AssertionError("expected startup failure")
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_mode_requires_strong_admin_password(tmp_path: Path) -> None:
|
||||||
|
try:
|
||||||
|
make_app(
|
||||||
|
DdnsSettings(
|
||||||
|
database_path=tmp_path / "ddns.sqlite3",
|
||||||
|
admin_password="admin",
|
||||||
|
public_base_url="http://ddns.example.net",
|
||||||
|
hostname_suffix="ddns.example.net",
|
||||||
|
dns_provider="cloudflare",
|
||||||
|
cloudflare_api_token="token",
|
||||||
|
cloudflare_zone_id="zone",
|
||||||
|
require_dns_provider=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "DDNS_ADMIN_PASSWORD" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected startup failure")
|
||||||
|
|
||||||
|
|
||||||
def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
|
def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
|
||||||
database_path = tmp_path / "ddns.sqlite3"
|
database_path = tmp_path / "ddns.sqlite3"
|
||||||
app = make_app(
|
app = make_app(
|
||||||
|
|||||||
Reference in new issue
Block a user