This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
DDNS service that updates Route53 and UniFi DNS policies.
|
||||
|
||||
Exported env vars for Route53:
|
||||
RECORDS_FILE - path to YAML file with all DNS records
|
||||
ROUTE53_HOSTED_ZONE_ID - AWS Route53 hosted zone ID (single zone supported)
|
||||
GLOBAL_SKIP_IPV4 - skip IPv4 updates globally
|
||||
GLOBAL_SKIP_IPV6 - skip IPv6 updates globally
|
||||
LOG_LEVEL - logging level (DEBUG, INFO, WARNING, ERROR; default: INFO)
|
||||
|
||||
Exported env vars for UniFi:
|
||||
UNIFI_HOST - UniFi controller URL (e.g., https://unifi.local:8443)
|
||||
UNIFI_SITE_ID - UniFi site ID
|
||||
UNIFI_API_TOKEN - UniFi API token
|
||||
UNIFI_VERIFY_SSL - verify SSL certificates (default: false)
|
||||
|
||||
Exported env vars for debugging:
|
||||
DEBUG - if true, starts debugpy and waits for a remote debugger connection on port 5678
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
if DEBUG:
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(("0.0.0.0", 5678))
|
||||
print("DEBUG: debugpy listening on 0.0.0.0:5678, waiting for debugger to attach...")
|
||||
debugpy.wait_for_client()
|
||||
print("DEBUG: debugger attached, resuming execution...")
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from route53_update import update_ipv4 as route53_update_ipv4, update_ipv6 as route53_update_ipv6
|
||||
from unifi_update import UnifiConfig, update_records as unifi_update_records
|
||||
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import Loader # type: ignore
|
||||
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
RECORDS_FILE = os.getenv("RECORDS_FILE")
|
||||
ROUTE53_HOSTED_ZONE_ID = os.getenv("ROUTE53_HOSTED_ZONE_ID")
|
||||
GLOBAL_SKIP_IPV4 = os.getenv("GLOBAL_SKIP_IPV4", "false").lower() == "true"
|
||||
GLOBAL_SKIP_IPV6 = os.getenv("GLOBAL_SKIP_IPV6", "false").lower() == "true"
|
||||
|
||||
UNIFI_HOST = os.getenv("UNIFI_HOST")
|
||||
UNIFI_SITE_ID = os.getenv("UNIFI_SITE_ID")
|
||||
UNIFI_API_TOKEN = os.getenv("UNIFI_API_TOKEN")
|
||||
UNIFI_VERIFY_SSL = os.getenv("UNIFI_VERIFY_SSL", "false").lower() == "true"
|
||||
|
||||
|
||||
class Route53RecordType(TypedDict):
|
||||
record: str
|
||||
provider: Literal["route53"]
|
||||
skip_ipv4: bool | None
|
||||
skip_ipv6: bool | None
|
||||
ttl_seconds: int | None
|
||||
|
||||
|
||||
class UnifiRecordType(TypedDict):
|
||||
record: str
|
||||
provider: Literal["unifi"]
|
||||
ttl_seconds: int | None
|
||||
|
||||
|
||||
class RecordYamlStruct(TypedDict):
|
||||
records: list[Route53RecordType | UnifiRecordType]
|
||||
|
||||
|
||||
def get_ipv4() -> str:
|
||||
logger.debug("Executing: curl -4 ifconfig.me")
|
||||
result = subprocess.run(["curl", "-4", "ifconfig.me"], capture_output=True)
|
||||
ip = result.stdout.decode()
|
||||
logger.debug("IPv4 response: %s", ip.strip())
|
||||
return ip
|
||||
|
||||
|
||||
def get_ipv6() -> str:
|
||||
logger.debug("Executing: curl -6 ifconfig.me")
|
||||
result = subprocess.run(["curl", "-6", "ifconfig.me"], capture_output=True)
|
||||
ip = result.stdout.decode()
|
||||
logger.debug("IPv6 response: %s", ip.strip())
|
||||
return ip
|
||||
|
||||
|
||||
def _update_route53_records(
|
||||
records: list[Route53RecordType],
|
||||
public_ipv4: str | None,
|
||||
public_ipv6: str | None,
|
||||
) -> None:
|
||||
logger.info("Processing %d Route53 record(s)", len(records))
|
||||
|
||||
for record in records:
|
||||
logger.info(
|
||||
"=== Processing Route53 record: %s (hosted_zone_id=%s) ===",
|
||||
record["record"],
|
||||
ROUTE53_HOSTED_ZONE_ID,
|
||||
)
|
||||
|
||||
if record.get("skip_ipv4"):
|
||||
logger.info("Skipping IPv4 for %s (skip_ipv4=true)", record["record"])
|
||||
elif GLOBAL_SKIP_IPV4 or not public_ipv4:
|
||||
logger.info("Skipping IPv4 for %s (global skip or no IPv4 available)", record["record"])
|
||||
else:
|
||||
logger.info("Updating IPv4 for %s -> %s", record["record"], public_ipv4)
|
||||
route53_update_ipv4(
|
||||
hosted_zone_id=ROUTE53_HOSTED_ZONE_ID, # type: ignore[arg-type]
|
||||
record=record["record"],
|
||||
public_ipv4=public_ipv4,
|
||||
)
|
||||
|
||||
if record.get("skip_ipv6"):
|
||||
logger.info("Skipping IPv6 for %s (skip_ipv6=true)", record["record"])
|
||||
elif GLOBAL_SKIP_IPV6 or not public_ipv6:
|
||||
logger.info("Skipping IPv6 for %s (global skip or no IPv6 available)", record["record"])
|
||||
else:
|
||||
logger.info("Updating IPv6 for %s -> %s", record["record"], public_ipv6)
|
||||
route53_update_ipv6(
|
||||
hosted_zone_id=ROUTE53_HOSTED_ZONE_ID, # type: ignore[arg-type]
|
||||
record=record["record"],
|
||||
public_ipv6=public_ipv6,
|
||||
)
|
||||
|
||||
logger.info("=== Done processing Route53 record: %s ===", record["record"])
|
||||
|
||||
|
||||
def _update_unifi_records(
|
||||
unifi_config: UnifiConfig,
|
||||
public_ipv4: str | None,
|
||||
public_ipv6: str | None,
|
||||
) -> None:
|
||||
logger.info("Processing %d UniFi record(s)", len(unifi_config["records"]))
|
||||
unifi_update_records(
|
||||
unifi_config=unifi_config,
|
||||
ipv4=public_ipv4,
|
||||
ipv6=public_ipv6,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logger.info("=== DDNS Update Starting ===")
|
||||
logger.debug("Log level: %s", log_level)
|
||||
logger.debug("RECORDS_FILE: %s", RECORDS_FILE)
|
||||
logger.debug("ROUTE53_HOSTED_ZONE_ID: %s", ROUTE53_HOSTED_ZONE_ID)
|
||||
logger.debug("GLOBAL_SKIP_IPV4: %s", GLOBAL_SKIP_IPV4)
|
||||
logger.debug("GLOBAL_SKIP_IPV6: %s", GLOBAL_SKIP_IPV6)
|
||||
logger.debug("UNIFI_HOST: %s", UNIFI_HOST)
|
||||
logger.debug("UNIFI_SITE_ID: %s", UNIFI_SITE_ID)
|
||||
logger.debug("UNIFI_API_TOKEN: %s", "****" if UNIFI_API_TOKEN else "not set")
|
||||
logger.debug("UNIFI_VERIFY_SSL: %s", UNIFI_VERIFY_SSL)
|
||||
|
||||
if not RECORDS_FILE:
|
||||
logger.error("RECORDS_FILE env var not found!")
|
||||
exit(1)
|
||||
|
||||
logger.info("Loading records file: %s", RECORDS_FILE)
|
||||
try:
|
||||
with open(RECORDS_FILE) as f:
|
||||
records_file_contents: RecordYamlStruct = yaml.load(f, Loader)
|
||||
logger.debug("Loaded %d record(s) from YAML", len(records_file_contents["records"]))
|
||||
except FileNotFoundError as e:
|
||||
logger.error("Records file not found: %s", e)
|
||||
sys.exit(1)
|
||||
|
||||
route53_records: list[Route53RecordType] = []
|
||||
unifi_records: list[UnifiRecordType] = []
|
||||
|
||||
for record in records_file_contents["records"]:
|
||||
provider = record.get("provider", "route53")
|
||||
logger.debug("Categorizing record %s as provider=%s", record["record"], provider)
|
||||
if provider == "unifi":
|
||||
unifi_records.append(record) # type: ignore[arg-type]
|
||||
else:
|
||||
route53_records.append(record) # type: ignore[arg-type]
|
||||
|
||||
logger.info("Found %d Route53 record(s) and %d UniFi record(s)", len(route53_records), len(unifi_records))
|
||||
|
||||
if GLOBAL_SKIP_IPV4:
|
||||
public_ipv4 = None
|
||||
logger.warning("Globally skipping IPv4.")
|
||||
else:
|
||||
logger.info("Fetching public IPv4 address from ifconfig.me")
|
||||
public_ipv4 = get_ipv4()
|
||||
if not public_ipv4:
|
||||
logger.error("Public IPv4 not found.")
|
||||
exit(1)
|
||||
logger.info("Public IPv4 is %s", public_ipv4)
|
||||
|
||||
if GLOBAL_SKIP_IPV6:
|
||||
public_ipv6 = None
|
||||
logger.warning("Globally skipping IPv6.")
|
||||
else:
|
||||
logger.info("Fetching public IPv6 address from ifconfig.me")
|
||||
public_ipv6 = get_ipv6()
|
||||
if not public_ipv6:
|
||||
logger.error("Public IPv6 not found.")
|
||||
exit(1)
|
||||
logger.info("Public IPv6 is %s", public_ipv6)
|
||||
|
||||
if route53_records:
|
||||
if not ROUTE53_HOSTED_ZONE_ID:
|
||||
logger.error("ROUTE53_HOSTED_ZONE_ID must be set for Route53 records!")
|
||||
exit(1)
|
||||
logger.info("=== Starting Route53 updates (hosted_zone_id=%s) ===", ROUTE53_HOSTED_ZONE_ID)
|
||||
_update_route53_records(route53_records, public_ipv4, public_ipv6)
|
||||
logger.info("=== Finished Route53 updates ===")
|
||||
|
||||
if unifi_records:
|
||||
logger.info("=== Starting UniFi updates ===")
|
||||
if not all([UNIFI_HOST, UNIFI_SITE_ID, UNIFI_API_TOKEN]):
|
||||
logger.error("UNIFI_HOST, UNIFI_SITE_ID, and UNIFI_API_TOKEN must be set for UniFi records!")
|
||||
exit(1)
|
||||
|
||||
unifi_config: UnifiConfig = {
|
||||
"host": UNIFI_HOST, # type: ignore
|
||||
"site_id": UNIFI_SITE_ID, # type: ignore
|
||||
"api_token": UNIFI_API_TOKEN, # type: ignore
|
||||
"verify_ssl": UNIFI_VERIFY_SSL,
|
||||
"records": unifi_records, # type: ignore[arg-type]
|
||||
}
|
||||
|
||||
_update_unifi_records(unifi_config, public_ipv4, public_ipv6)
|
||||
logger.info("=== Finished UniFi updates ===")
|
||||
|
||||
logger.info("=== DDNS Update Complete ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user