Prepare KarlDNS production deployment

This commit is contained in:
StasonJatham committed 2026-05-21 17:23:08 +02:00
1 parent 5a90bd9d87
commit ce5b4e0ff2
101 files changed
+3364 -2018

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from .base import (
DnsConfigurationError,
DnsProviderError,
DnsReconciliationError,
delete_dns_records,
dns_backend_configured,
publish_dns,
validate_dns_provider_settings,
)
from .cloudflare import delete_cloudflare_records, publish_cloudflare
from .rfc2136 import delete_rfc2136_records, publish_rfc2136
__all__ = [
"DnsConfigurationError",
"DnsProviderError",
"DnsReconciliationError",
"delete_cloudflare_records",
"delete_dns_records",
"delete_rfc2136_records",
"dns_backend_configured",
"publish_cloudflare",
"publish_dns",
"publish_rfc2136",
"validate_dns_provider_settings",
]
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from ..models import UpdateResult
from ..settings import DdnsSettings
SUPPORTED_DNS_PROVIDERS = {"", "cloudflare", "rfc2136"}
class DnsProviderError(RuntimeError):
"""Raised for expected DNS provider publish/delete failures."""
class DnsReconciliationError(DnsProviderError):
"""Raised when provider read-back does not match the requested DNS state."""
class DnsConfigurationError(RuntimeError):
"""Raised when DNS provider settings are invalid at startup."""
def publish_dns(result: UpdateResult, settings: DdnsSettings) -> None:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
if provider == "cloudflare":
from .cloudflare import publish_cloudflare
publish_cloudflare(result, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
from .rfc2136 import publish_rfc2136
publish_rfc2136(result, settings)
return
if settings.require_dns_provider:
raise DnsProviderError("DNS provider is required but not configured")
def delete_dns_records(hostname: str, settings: DdnsSettings) -> None:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
if provider == "cloudflare":
from .cloudflare import delete_cloudflare_records
delete_cloudflare_records(hostname, settings)
return
if settings.rfc2136_server or provider == "rfc2136":
from .rfc2136 import delete_rfc2136_records
delete_rfc2136_records(hostname, settings)
return
if settings.require_dns_provider:
raise DnsProviderError("DNS provider is required but not configured")
def dns_backend_configured(settings: DdnsSettings) -> bool:
validate_dns_provider_settings(settings)
provider = _normalized_provider(settings)
return provider == "cloudflare" or provider == "rfc2136" or bool(settings.rfc2136_server)
def validate_dns_provider_settings(settings: DdnsSettings) -> None:
provider = _normalized_provider(settings)
if provider not in SUPPORTED_DNS_PROVIDERS:
raise DnsConfigurationError(f"Unsupported DDNS_DNS_PROVIDER: {settings.dns_provider}")
if not settings.require_dns_provider:
return
if provider == "cloudflare":
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsConfigurationError(
"DDNS_CLOUDFLARE_API_TOKEN and DDNS_CLOUDFLARE_ZONE_ID are required when DDNS_DNS_PROVIDER=cloudflare"
)
return
if provider == "rfc2136" or settings.rfc2136_server:
if not settings.rfc2136_server or not settings.rfc2136_zone:
raise DnsConfigurationError(
"DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE are required when RFC2136 DNS publishing is enabled"
)
return
raise DnsConfigurationError("DDNS_REQUIRE_DNS_PROVIDER is set but no DNS backend is configured")
def _normalized_provider(settings: DdnsSettings) -> str:
return settings.dns_provider.strip().lower()
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from ..models import UpdateResult
from ..settings import DdnsSettings
from .base import DnsProviderError, DnsReconciliationError
def publish_cloudflare(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.cloudflare_api_token or not settings.cloudflare_zone_id:
raise DnsProviderError("Cloudflare DNS provider is missing required credentials")
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 DnsProviderError("Cloudflare DNS provider is missing required credentials")
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)
_cloudflare_reconcile_record(settings, hostname, record_type, value)
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_reconcile_record(settings: DdnsSettings, hostname: str, record_type: str, value: str) -> None:
record = _cloudflare_find_record(settings, hostname, record_type)
if not record:
raise DnsReconciliationError(f"Cloudflare {record_type} record was not visible after publish")
if record.get("content") != value:
raise DnsReconciliationError(f"Cloudflare {record_type} record did not match requested content after publish")
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:
raise DnsProviderError(f"Cloudflare API returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise DnsProviderError("Cloudflare API request failed") from exc
except json.JSONDecodeError as exc:
raise DnsProviderError("Cloudflare API returned invalid JSON") from exc
if not parsed.get("success", False):
raise DnsProviderError("Cloudflare API returned an unsuccessful response")
return parsed
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from ..models import UpdateResult
from ..settings import DdnsSettings
from .base import DnsProviderError
def publish_rfc2136(result: UpdateResult, settings: DdnsSettings) -> None:
if not settings.rfc2136_server:
return
import dns.exception
import dns.query
import dns.rcode
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")
try:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
except dns.exception.DNSException as exc:
raise DnsProviderError("RFC2136 DNS update failed") from exc
rcode = response.rcode()
if rcode != 0:
raise DnsProviderError(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.exception
import dns.query
import dns.rcode
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")
try:
response = dns.query.tcp(update, settings.rfc2136_server, timeout=5)
except dns.exception.DNSException as exc:
raise DnsProviderError("RFC2136 DNS delete failed") from exc
rcode = response.rcode()
if rcode != 0:
raise DnsProviderError(f"RFC2136 delete failed: {dns.rcode.to_text(rcode)}")