Require karl prefixed bearer secrets

This commit is contained in:
StasonJatham committed 2026-05-21 18:36:06 +02:00
1 parent 8f07bbde84
commit d2cad52ae9
6 files changed
+33 -29

No files matched your search

+1 -1
View File
@@ -305,7 +305,7 @@ The create response includes `registration_url`, `update_url`, and `dashboard_ur
Use the dashboard secret to rotate the update URL: Use the dashboard secret to rotate the update URL:
```bash ```bash
curl -sS -X POST https://karldns.de/api/v1/hostnames/<dashboard-secret>/rotate-update-url curl -sS -X POST https://karldns.de/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url
``` ```
The response contains a replacement `update_url`. The old update URL stops working immediately. The response contains a replacement `update_url`. The old update URL stops working immediately.
+4 -4
View File
@@ -4,8 +4,8 @@ from typing import Annotated
from fastapi import Path from fastapi import Path
TOKEN_URLSAFE_PATTERN = r"^[A-Za-z0-9_-]+$" TOKEN_URLSAFE_PATTERN = r"^karl_[A-Za-z0-9_-]+$"
TOKEN_MIN_LENGTH = 32 TOKEN_MIN_LENGTH = 37
TOKEN_MAX_LENGTH = 128 TOKEN_MAX_LENGTH = 128
UpdateSlugPath = Annotated[ UpdateSlugPath = Annotated[
@@ -14,7 +14,7 @@ UpdateSlugPath = Annotated[
min_length=TOKEN_MIN_LENGTH, min_length=TOKEN_MIN_LENGTH,
max_length=TOKEN_MAX_LENGTH, max_length=TOKEN_MAX_LENGTH,
pattern=TOKEN_URLSAFE_PATTERN, pattern=TOKEN_URLSAFE_PATTERN,
description="Private update URL value.", description="Private update URL value. Must start with karl_.",
), ),
] ]
ManagementSlugPath = Annotated[ ManagementSlugPath = Annotated[
@@ -23,6 +23,6 @@ ManagementSlugPath = Annotated[
min_length=TOKEN_MIN_LENGTH, min_length=TOKEN_MIN_LENGTH,
max_length=TOKEN_MAX_LENGTH, max_length=TOKEN_MAX_LENGTH,
pattern=TOKEN_URLSAFE_PATTERN, pattern=TOKEN_URLSAFE_PATTERN,
description="Private dashboard URL value.", description="Private dashboard URL value. Must start with karl_.",
), ),
] ]
+9 -9
View File
@@ -159,10 +159,10 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
base_url = settings.public_base_url.rstrip("/") base_url = settings.public_base_url.rstrip("/")
example_ipv4, _ = _example_update_ips(request_ip) example_ipv4, _ = _example_update_ips(request_ip)
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames" create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/<dashboard-secret>?myip={example_ipv4}'" register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'"
update_example = f"curl -sS '{base_url}/api/v1/router/update/<update-secret>?myip={example_ipv4}'" update_example = f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'"
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<dashboard-secret>?history_limit=10'" status_example = f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'"
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<dashboard-secret>/rotate-update-url" rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url"
schema_example = f"curl -sS {base_url}/openapi.json" schema_example = f"curl -sS {base_url}/openapi.json"
description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames." description = "Small KarlDNS API guide for creating, activating, updating, reading, and deleting generated Dynamic DNS hostnames."
return _page( return _page(
@@ -183,11 +183,11 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
<h2>Endpoints</h2> <h2>Endpoints</h2>
<ol class="simple-steps text-start"> <ol class="simple-steps text-start">
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and private setup values. The response includes <code>registration_url</code>.</li> <li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and private setup values. The response includes <code>registration_url</code>.</li>
<li><strong>GET or POST /api/v1/router/register/&lt;secret&gt;.</strong> Register the hostname after secrets are saved. Uses the request IP, or <code>myip</code>/<code>myipv6</code>, for the initial DNS record.</li> <li><strong>GET or POST /api/v1/router/register/&lt;karl_secret&gt;.</strong> Register the hostname after secrets are saved. Uses the request IP, or <code>myip</code>/<code>myipv6</code>, for the initial DNS record.</li>
<li><strong>GET or POST /api/v1/router/update/&lt;secret&gt;.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li> <li><strong>GET or POST /api/v1/router/update/&lt;karl_secret&gt;.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
<li><strong>GET /api/v1/hostnames/&lt;secret&gt;.</strong> Read hostname status and recent history.</li> <li><strong>GET /api/v1/hostnames/&lt;karl_secret&gt;.</strong> Read hostname status and recent history.</li>
<li><strong>POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li> <li><strong>POST /api/v1/hostnames/&lt;karl_secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li>
<li><strong>DELETE /api/v1/hostnames/&lt;secret&gt;.</strong> Delete hostname and DNS records.</li> <li><strong>DELETE /api/v1/hostnames/&lt;karl_secret&gt;.</strong> Delete hostname and DNS records.</li>
</ol> </ol>
</div> </div>
<div> <div>
+5 -5
View File
@@ -30,10 +30,10 @@ def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> No
assert redoc.status_code == 404 assert redoc.status_code == 404
assert "Small API surface." in docs.text assert "Small API surface." in docs.text
assert "POST /api/v1/hostnames" in docs.text assert "POST /api/v1/hostnames" in docs.text
assert "GET or POST /api/v1/router/register/&lt;secret&gt;" in docs.text assert "GET or POST /api/v1/router/register/&lt;karl_secret&gt;" in docs.text
assert "GET or POST /api/v1/router/update/&lt;secret&gt;" in docs.text assert "GET or POST /api/v1/router/update/&lt;karl_secret&gt;" in docs.text
assert "GET /api/v1/hostnames/&lt;secret&gt;" in docs.text assert "GET /api/v1/hostnames/&lt;karl_secret&gt;" in docs.text
assert "POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url" in docs.text assert "POST /api/v1/hostnames/&lt;karl_secret&gt;/rotate-update-url" in docs.text
assert "Rotate update URL" in docs.text assert "Rotate update URL" in docs.text
assert "Operator API" not in docs.text assert "Operator API" not in docs.text
assert "/api/v1/admin" not in docs.text assert "/api/v1/admin" not in docs.text
@@ -242,7 +242,7 @@ def test_hostname_api_history_is_scoped_to_dashboard_secret(tmp_path: Path) -> N
assert "source_ip" not in payload["history"][0] assert "source_ip" not in payload["history"][0]
assert "other.ddns.example.net" not in response.text assert "other.ddns.example.net" not in response.text
assert client.get(f"/api/v1/hostnames/{other['management_slug']}", params={"history_limit": 101}).status_code == 422 assert client.get(f"/api/v1/hostnames/{other['management_slug']}", params={"history_limit": 101}).status_code == 422
assert client.get(f"/api/v1/hostnames/{'a' * 32}").status_code == 404 assert client.get(f"/api/v1/hostnames/karl_{'a' * 32}").status_code == 404
def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path: Path, monkeypatch) -> None: def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path: Path, monkeypatch) -> None:
+7 -7
View File
@@ -104,9 +104,9 @@ printf 'updater placeholder\\n'
EOS EOS
;; ;;
*'/api/v1/hostnames'*) *'/api/v1/hostnames'*)
printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}' printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/karl_update_secret_12345678901234567890?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/karl_manage_secret_12345678901234567890","registration_url":"https://karldns.test/api/v1/router/register/karl_manage_secret_12345678901234567890"}}'
;; ;;
*'/api/v1/router/register/manage-secret'*) *'/api/v1/router/register/karl_manage_secret_12345678901234567890'*)
printf '%s\\n' 'good registered' printf '%s\\n' 'good registered'
;; ;;
*) *)
@@ -136,11 +136,11 @@ esac
log = log_file.read_text(encoding="utf-8") log = log_file.read_text(encoding="utf-8")
assert "/api/v1/hostnames" in log assert "/api/v1/hostnames" in log
assert "/api/v1/router/register/manage-secret" in log assert "/api/v1/router/register/karl_manage_secret_12345678901234567890" in log
assert "KarlDNS: registering hostname" in result.stdout assert "KarlDNS: registering hostname" in result.stdout
config = home_dir / ".config" / "karldns" / "updater.env" config = home_dir / ".config" / "karldns" / "updater.env"
config_text = config.read_text(encoding="utf-8") config_text = config.read_text(encoding="utf-8")
assert "KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/update-secret'" in config_text assert "KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/karl_update_secret_12345678901234567890'" in config_text
assert "KARLDNS_HOSTNAME='abc.karldns.test'" in config_text assert "KARLDNS_HOSTNAME='abc.karldns.test'" in config_text
@@ -172,9 +172,9 @@ printf 'updater placeholder\\n'
EOS EOS
;; ;;
*'/api/v1/hostnames'*) *'/api/v1/hostnames'*)
printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}' printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/karl_update_secret_12345678901234567890?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/karl_manage_secret_12345678901234567890","registration_url":"https://karldns.test/api/v1/router/register/karl_manage_secret_12345678901234567890"}}'
;; ;;
*'/api/v1/router/register/manage-secret'*) *'/api/v1/router/register/karl_manage_secret_12345678901234567890'*)
printf '%s\\n' 'good registered' printf '%s\\n' 'good registered'
;; ;;
*) *)
@@ -220,7 +220,7 @@ cp "$1" {crontab_file}
assert "KARLDNS_CONFIG=" in cron_text assert "KARLDNS_CONFIG=" in cron_text
assert "karldns-update" in cron_text assert "karldns-update" in cron_text
assert log.count("/api/v1/hostnames") == 1 assert log.count("/api/v1/hostnames") == 1
assert log.count("/api/v1/router/register/manage-secret") == 1 assert log.count("/api/v1/router/register/karl_manage_secret_12345678901234567890") == 1
assert (home_dir / ".local" / "bin" / "karldns-update").exists() assert (home_dir / ".local" / "bin" / "karldns-update").exists()
run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", env=env) run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", env=env)
+7 -3
View File
@@ -21,7 +21,7 @@ def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
client = TestClient(app) client = TestClient(app)
response = client.get( response = client.get(
f"/api/v1/router/update/{'a' * 43}", f"/api/v1/router/update/karl_{'a' * 43}",
params={"hostname": "public-target.ddns.example.net", "myip": "203.0.113.9"}, params={"hostname": "public-target.ddns.example.net", "myip": "203.0.113.9"},
) )
@@ -56,8 +56,8 @@ def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
) )
client = TestClient(app) client = TestClient(app)
first = client.get(f"/m/{'a' * 43}") first = client.get(f"/m/karl_{'a' * 43}")
second = client.get(f"/m/{'b' * 43}") second = client.get(f"/m/karl_{'b' * 43}")
assert first.status_code == 404 assert first.status_code == 404
assert second.status_code == 429 assert second.status_code == 429
@@ -92,6 +92,10 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path
"/api/v1/router/register/short", "/api/v1/router/register/short",
"/api/v1/hostnames/short", "/api/v1/hostnames/short",
"/m/short", "/m/short",
"/api/v1/router/update/not_karl_but_long_enough_1234567890",
"/api/v1/router/register/not_karl_but_long_enough_1234567890",
"/api/v1/hostnames/not_karl_but_long_enough_1234567890",
"/m/not_karl_but_long_enough_1234567890",
], ],
) )
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None: def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None: