Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
@@ -0,0 +1,321 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
|
||||
from router_dyndns.ddns_dns import validate_dns_provider_settings
|
||||
|
||||
|
||||
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
store.create_account("public-target.ddns.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "public-target.ddns.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
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_shared_secret_without_allowed_hosts_cannot_update_arbitrary_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
app = make_app(DdnsSettings(database_path=database_path, shared_secret="secret", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.9", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.text == "badauth"
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
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_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_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/magic", data={"username": "router"})
|
||||
|
||||
assert created.status_code == 200
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
assert created.headers["pragma"] == "no-cache"
|
||||
|
||||
|
||||
def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
max_request_body_bytes=1024,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/magic", data={"username": "x" * 2000})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_invalid_utf8_form_body_is_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/magic",
|
||||
content=b"username=\xff",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "form body must be valid UTF-8"
|
||||
|
||||
|
||||
def test_too_many_form_fields_are_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
body = "&".join(f"k{index}=v" for index in range(101))
|
||||
|
||||
response = client.post(
|
||||
"/magic",
|
||||
content=body.encode("ascii"),
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "form must not contain more than 100 fields"
|
||||
|
||||
|
||||
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_dns_delete_failure_is_logged_without_secret_management_slug(tmp_path: Path, monkeypatch, caplog) -> None:
|
||||
def fail_delete(hostname, settings):
|
||||
raise DnsProviderError("dns down")
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
|
||||
monkeypatch.setattr(ddns_service, "delete_dns_records", fail_delete)
|
||||
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"))
|
||||
|
||||
with pytest.raises(DnsProviderError):
|
||||
app.state.service.delete_account("home.example.net", "unit-test")
|
||||
|
||||
assert store.get_account_by_slug(account["update_slug"]) is not None
|
||||
assert store.list_update_events()[0]["status"] == "dns_delete_failed"
|
||||
assert "DNS delete failed for hostname=home.example.net source=unit-test" in caplog.text
|
||||
assert account["management_slug"] not in caplog.text
|
||||
assert "dns down" not in caplog.text
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def test_invalid_dns_provider_is_rejected() -> None:
|
||||
settings = DdnsSettings(dns_provider="route53")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unsupported DDNS_DNS_PROVIDER"):
|
||||
validate_dns_provider_settings(settings)
|
||||
|
||||
|
||||
def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) -> None:
|
||||
with pytest.raises(RuntimeError, match="DDNS_CLOUDFLARE_API_TOKEN"):
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="a" * 16,
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
dns_provider="cloudflare",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> None:
|
||||
with pytest.raises(RuntimeError, match="DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE"):
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="a" * 16,
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
dns_provider="rfc2136",
|
||||
rfc2136_server="192.0.2.53",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
|
||||
import dns.query
|
||||
import dns.rcode
|
||||
|
||||
from router_dyndns.dns.rfc2136 import delete_rfc2136_records
|
||||
|
||||
class RefusedResponse:
|
||||
def rcode(self) -> int:
|
||||
return dns.rcode.REFUSED
|
||||
|
||||
monkeypatch.setattr(dns.query, "tcp", lambda *args, **kwargs: RefusedResponse())
|
||||
|
||||
with pytest.raises(DnsProviderError, match="RFC2136 delete failed"):
|
||||
delete_rfc2136_records(
|
||||
"home.example.net",
|
||||
DdnsSettings(rfc2136_server="192.0.2.53", rfc2136_zone="example.net"),
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_integer_env_value_fails_startup(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DDNS_RATE_LIMIT_PER_MINUTE", "not-an-int")
|
||||
|
||||
with pytest.raises(ValueError, match="DDNS_RATE_LIMIT_PER_MINUTE must be an integer"):
|
||||
DdnsSettings.from_env()
|
||||
|
||||
|
||||
def test_invalid_admin_path_fails_startup() -> None:
|
||||
with pytest.raises(ValueError, match="admin_path"):
|
||||
DdnsSettings(admin_path="admin")
|
||||
|
||||
with pytest.raises(ValueError, match="admin_path"):
|
||||
DdnsSettings(admin_path="/")
|
||||
|
||||
with pytest.raises(ValueError, match="admin_path"):
|
||||
DdnsSettings(admin_path="/admin?")
|
||||
|
||||
with pytest.raises(ValueError, match="public route"):
|
||||
DdnsSettings(admin_path="/api")
|
||||
|
||||
|
||||
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")
|
||||
Reference in new issue
Block a user