diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2a673ca1e..f5fc13626 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,9 @@ updates: schedule: interval: "weekly" groups: + pydantic-deps: + patterns: + - "pydantic*" pip-deps: patterns: - "*" diff --git a/default.env b/default.env index 52c23e2b4..5fc32c88e 100644 --- a/default.env +++ b/default.env @@ -56,38 +56,59 @@ CONTRIBUTOOR_USERNAME= CONTRIBUTOOR_PASSWORD= # Secure web proxy - advanced use, please see instructions -DOMAIN=example.com +# Email to be used with Let's Encrypt certificates ACME_EMAIL=user@example.com +# Cloudflare tokens and zone id, if using traefik-cf.yml CF_DNS_API_TOKEN=SECRETTOKEN CF_ZONE_API_TOKEN= CF_ZONE_ID= +# AWS profile (optional) or access keys, zone id, and region, if using traefik-aws.yml AWS_PROFILE= -AWS_HOSTED_ZONE_ID= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= -CNAME_LIST= +AWS_HOSTED_ZONE_ID= AWS_REGION=us-east-2 +# TTL for all created records TTL=300 +# The domain that all records are under, has to match the zone id of the provider +DOMAIN=example.com +# The host record that is updated with the external IP address of this host, A and AAAA records +DDNS_HOST=node +# Comma-separated list of CNAME records to create, pointing at "DDNS_HOST.DOMAIN" +# All entries will have the DOMAIN appended, e.g. grafana creates a CNAME entry grafana.example.com +# When using Cloudflare, each entry can receive an optional proxy override, like so: +# grafana:proxy,rpc:noproxy,prometheus +CNAME_LIST= +# Whether to default to proxied or not in CloudFlare. +CF_PROXY=false +# Host names used in their respective *-traefik.yml files, to be reachable via traefik +# Grafana, Siren and Prysm Web are exposed via traefik by default, if traefik is in use GRAFANA_HOST=grafana SIREN_HOST=siren PRYSM_HOST=prysm +# ee-traefik.yml. This is EXTREMELY niche, do not expose engine API just because EE_HOST=engine +# el-traefik.yml to expose the execution RPC and WS APIs EL_HOST=rpc EL_LB=rpc-lb EL_WS_HOST=ws EL_WS_LB=ws-lb +# rpc-proxy-traefik.yml, to expose the RPC and WS APIs of the Nimbus Verified Proxy PROXY_RPC_HOST=rpc PROXY_WS_HOST=ws +# cl-traefik.yml, to expose the consensus REST API CL_HOST=cl CL_LB=cl-lb +# Specific to Lighthouse, exposes the keymanager API of the Lighthouse VC, used for Siren VC_HOST=vc +# prometheus-traefik.yml, expose the Prometheus port PROM_HOST=prometheus +# loki-traefik.yml, expose the Loki port LOKI_HOST=loki +# tempo-traefik.yml, expose the Tempo ports TEMPO_GRPC_HOST=tempo-grpc TEMPO_HTTP_HOST=tempo-http -DDNS_SUBDOMAIN=node -DDNS_PROXY=false # Some clients suggest adjusting to higher (or lower) peer count. Adjust here, per client # Nimbus peer count should not be set below 70. CL_MIN_PEER_COUNT is used for Teku only. @@ -504,4 +525,4 @@ DOCKER_ROOT=/var/lib/docker DOCKER_SOCK=/var/run/docker.sock # Used by ethd update - please do not adjust -ENV_VERSION=57 +ENV_VERSION=58 diff --git a/ethd b/ethd index f6b0b4816..cbb93dfe9 100755 --- a/ethd +++ b/ethd @@ -1761,9 +1761,9 @@ __update_value_in_env() { } -__env_migrate() { - local old_vars=( ) - local new_vars=( ) +__migrate_env() { + local old_vars=( DDNS_PROXY DDNS_SUBDOMAIN ) + local new_vars=( CF_PROXY DDNS_HOST ) local error local line local index @@ -2380,7 +2380,7 @@ update() { __non_interactive=1 fi - __env_migrate + __migrate_env if [[ "${__env_migrated}" -eq 1 ]] && ! cmp -s "${__env_file}" "${__env_file}".source; then # Create .bak early ${__as_owner} cp "${__env_file}".source "${__env_file}".bak fi diff --git a/tests/test-traefik.sh b/tests/test-traefik.sh new file mode 100755 index 000000000..2fca1f9f1 --- /dev/null +++ b/tests/test-traefik.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# +# Test script for the DNS updater (Route53 + Cloudflare providers). +# +# Usage: +# ./test-traefik.sh [--route53] [--cloudflare] [--both] [--dry-run] +# +# Defaults to --both if no provider flag is given. +# +# Assumes: +# - This script lives in /tests +# - /.env exists with real credentials filled in +# +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +eth_docker_dir="${script_dir}/.." +env_file="${eth_docker_dir}/.env" + +red='\033[0;31m' +green='\033[0;32m' +yellow='\033[1;33m' +nc='\033[0m' + +test_r53=false +test_cf=false +dry_run=false + +for arg in "$@"; do + case "$arg" in + --route53) test_r53=true ;; + --cloudflare) test_cf=true ;; + --both) test_r53=true; test_cf=true ;; + --dry-run) dry_run=true ;; + --help|-h) + echo "Usage: $0 [--route53] [--cloudflare] [--both] [--dry-run]" + exit 0 + ;; + *) + echo -e "${red}Unknown argument: $arg${nc}" >&2 + exit 1 + ;; + esac +done + +if [[ "$test_r53" == false && "$test_cf" == false ]]; then + test_r53=true + test_cf=true +fi + +log_pass() { echo -e " ${green}✓${nc} $1"; } +log_fail() { echo -e " ${red}✗${nc} $1"; } +log_info() { echo -e " $1"; } +log_section() { echo -e "\n${yellow}--- $1 ---${nc}"; } + +env_var() { + local var="$1" + if [[ ! -f "$env_file" ]]; then + echo "" + return + fi + grep -E "^${var}=" "$env_file" | head -1 | cut -d'=' -f2- | sed 's/[[:space:]]*$//' | sed "s/^['\"]//;s/['\"]$//" +} + +has_real_value() { + local val + val="$(env_var "$1")" + [[ -n "$val" && "$val" != "SECRETTOKEN" && "$val" != "example.com" && "$val" != "user@example.com" ]] +} + +# Build the ddns service image via the actual compose file +build_ddns() { + local compose_file="$1" + log_section "Building ddns image via ${compose_file}" + if $dry_run; then + echo " (dry-run: docker compose -f ${compose_file} --env-file ${env_file} build ddns)" + return 0 + fi + docker compose -f "${compose_file}" --env-file "${env_file}" build ddns 2>&1 +} + +# Run the ddns service with timeout. Compose interpolates .env vars; +# any -e flags passed here override them in the container. +run_ddns() { + local compose_file="$1" + local timeout_sec="${2:-20}" + shift 2 + + if $dry_run; then + echo " (dry-run: docker compose -f ${compose_file} --env-file ${env_file} run --rm ddns $*)" + echo "(dry-run output placeholder)" + return 0 + fi + + timeout "${timeout_sec}" docker compose \ + -f "${compose_file}" \ + --env-file "${env_file}" \ + run --rm ddns "$@" 2>&1 || true +} + +# ============================================================================ +# Prerequisite checks +# ============================================================================ + +echo -e "\n${yellow}[setup]${nc} Checking prerequisites..." + +if [[ ! -f "$eth_docker_dir/ethd" ]]; then + echo -e "${red}eth-docker directory not found at ${eth_docker_dir}${nc}" >&2 + exit 1 +fi +log_pass "Found eth-docker at ${eth_docker_dir}" + +if [[ ! -f "$env_file" ]]; then + echo -e "${red}.env not found at ${env_file}${nc}" >&2 + exit 1 +fi +log_pass "Found .env file" + +if ! command -v docker &>/dev/null; then + echo -e "${red}docker not found in PATH${nc}" >&2 + exit 1 +fi +log_pass "docker is available" + +if ! docker compose version &>/dev/null; then + echo -e "${red}docker compose not available${nc}" >&2 + exit 1 +fi +log_pass "docker compose is available" + +# ============================================================================ +# Route53 Tests +# ============================================================================ + +compose_aws="${eth_docker_dir}/traefik-aws.yml" + +if $test_r53; then + echo -e "\n========================================" + echo -e " Route53 Provider Tests" + echo -e "========================================" + + r53_can_run=true + + HZ_ID="$(env_var AWS_HOSTED_ZONE_ID)" + + if [[ -z "$HZ_ID" || "$HZ_ID" == *"example"* ]]; then + log_fail "AWS_HOSTED_ZONE_ID is not configured" + r53_can_run=false + else + log_pass "AWS_HOSTED_ZONE_ID is set (${HZ_ID})" + fi + + has_profile=false + has_keys=false + + if has_real_value AWS_PROFILE; then + has_profile=true + log_pass "AWS_PROFILE is set ($(env_var AWS_PROFILE))" + fi + + if has_real_value AWS_ACCESS_KEY_ID && has_real_value AWS_SECRET_ACCESS_KEY; then + has_keys=true + log_pass "AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY are set" + fi + + if ! $has_profile && ! $has_keys; then + log_fail "No valid AWS auth (need AWS_PROFILE or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)" + r53_can_run=false + fi + + if [[ ! -f "$compose_aws" ]]; then + log_fail "traefik-aws.yml not found at ${compose_aws}" + r53_can_run=false + fi + + if ! $r53_can_run; then + echo -e "\n${yellow}Route53 tests skipped — missing credentials or compose file.${nc}" + echo " Fill in the relevant variables in ${env_file} and re-run." + else + build_ddns "$compose_aws" + + # Test 1: Basic update cycle + # Compose interpolates DDNS_HOST, DOMAIN, credentials, CNAME_LIST from .env. + # We only override test-specific vars. + log_section "Test: Route53 basic update cycle" + + output=$(run_ddns "$compose_aws" 20 \ + -e "SLEEP=2" \ + -e "TTL=60" \ + -e "LOG_LEVEL=debug") + + if echo "$output" | grep -q "Updated A record"; then + log_pass "A record was updated" + elif echo "$output" | grep -q "already up-to-date"; then + log_pass "A record is already up-to-date (no change needed)" + else + log_fail "No record update detected" + echo " Output snippet:" + echo "$output" | tail -20 | sed 's/^/ /' + fi + + if echo "$output" | grep -q "AWS identity:"; then + log_pass "AWS identity verified (validate() succeeded)" + else + log_fail "AWS identity check not found in logs" + fi + + if echo "$output" | grep -q "Using AWS credentials from environment"; then + log_pass "Auth via environment variables" + elif echo "$output" | grep -q "Using AWS profile"; then + log_pass "Auth via AWS profile" + fi + + if echo "$output" | grep -q "Using default AWS profile"; then + log_pass "Auth via default profile fallback" + fi + + # Test 2: Missing credentials (expected failure) + log_section "Test: Route53 missing credentials (expected failure)" + + fail_output=$(run_ddns "$compose_aws" 10 \ + -e "AWS_ACCESS_KEY_ID=" \ + -e "AWS_SECRET_ACCESS_KEY=" \ + -e "AWS_PROFILE=" \ + -e "SLEEP=2") + + if echo "$fail_output" | grep -q "No valid AWS credentials found"; then + log_pass "Gracefully rejected missing credentials" + else + log_fail "Did not reject missing credentials as expected" + echo " Output: $(echo "$fail_output" | tail -3)" + fi + + # Test 3: Unknown provider (expected failure) + log_section "Test: Unknown provider (expected failure)" + + fail_output=$(run_ddns "$compose_aws" 10 \ + -e "DNS_PROVIDER=bind" \ + -e "SLEEP=2") + + if echo "$fail_output" | grep -q "Unknown DNS_PROVIDER"; then + log_pass "Gracefully rejected unknown provider" + else + log_fail "Did not reject unknown provider as expected" + echo " Output: $(echo "$fail_output" | tail -3)" + fi + fi +fi + +# ============================================================================ +# Cloudflare Tests +# ============================================================================ + +compose_cf="${eth_docker_dir}/traefik-cf.yml" + +if $test_cf; then + echo -e "\n========================================" + echo -e " Cloudflare Provider Tests" + echo -e "========================================" + + cf_can_run=true + + ZONE_ID="$(env_var CF_ZONE_ID)" + + if [[ -z "$ZONE_ID" || "$ZONE_ID" == *"example"* ]]; then + log_fail "CF_ZONE_ID is not configured" + cf_can_run=false + else + log_pass "CF_ZONE_ID is set (${ZONE_ID})" + fi + + if ! has_real_value CF_DNS_API_TOKEN; then + log_fail "CF_DNS_API_TOKEN is not set (or still default)" + cf_can_run=false + else + log_pass "CF_DNS_API_TOKEN is set" + fi + + if [[ ! -f "$compose_cf" ]]; then + log_fail "traefik-cf.yml not found at ${compose_cf}" + cf_can_run=false + fi + + if ! $cf_can_run; then + echo -e "\n${yellow}Cloudflare tests skipped — missing credentials or compose file.${nc}" + echo " Fill in the relevant variables in ${env_file} and re-run." + else + build_ddns "$compose_cf" + + # Test 1: Basic update cycle + # Compose interpolates CF_ZONE_ID, CF_DNS_API_TOKEN, DDNS_HOST, DOMAIN, etc. from .env. + # We only override test-specific vars. + log_section "Test: Cloudflare basic update cycle" + + output=$(run_ddns "$compose_cf" 20 \ + -e "SLEEP=2" \ + -e "TTL=60" \ + -e "LOG_LEVEL=debug") + + if echo "$output" | grep -q "Updated A record"; then + log_pass "A record was updated" + elif echo "$output" | grep -q "already up-to-date"; then + log_pass "A record is already up-to-date (no change needed)" + else + log_fail "No record update detected" + echo " Output snippet:" + echo "$output" | tail -20 | sed 's/^/ /' + fi + + if echo "$output" | grep -q "Cloudflare validation failed"; then + log_fail "Cloudflare validation failed — check token permissions" + else + log_pass "Cloudflare validation passed" + fi + + # Test 2: Invalid token (expected failure) + log_section "Test: Cloudflare invalid token (expected failure)" + + fail_output=$(run_ddns "$compose_cf" 10 \ + -e "CF_DNS_API_TOKEN=invalid-token-should-fail" \ + -e "SLEEP=2") + + if echo "$fail_output" | grep -q "Cloudflare validation failed"; then + log_pass "Gracefully rejected invalid token" + else + log_fail "Did not reject invalid token as expected" + echo " Output: $(echo "$fail_output" | tail -3)" + fi + fi +fi + +echo -e "\n${green}All requested tests complete.${nc}" diff --git a/tests/testing-traefik.md b/tests/testing-traefik.md new file mode 100644 index 000000000..72c5803cc --- /dev/null +++ b/tests/testing-traefik.md @@ -0,0 +1,360 @@ +# End-to-End Testing Guide + +This document covers how to run end-to-end tests for the DNS updater with both Route53 and Cloudflare providers, including corner cases and expected failure modes. + +## Architecture Overview + +``` +updater.py ← main loop, IP fetching, signal handling +base.py ← DNSProvider ABC, registry, build_provider() +route53_provider.py ← Route53Provider + AwsCredentialResolver +cf_provider.py ← CloudflareProvider +privilege.py ← PrivilegeManager (root-only: copy_aws_config, drop) +``` + +**Import graph** (no cycles): +``` +updater.py → base.py, privilege.py, route53_provider.py, cf_provider.py +route53_provider.py → base.py +cf_provider.py → base.py +``` + +## Prerequisites + +### Docker (required for privilege management) + +The container must start as `root` so it can: +1. Copy `/root/.aws` (mounted from host) into the target user's home +2. `chown` and set permissions on the copied files +3. Drop privileges to the `RUN_AS_USER` (default: `dns`) + +### Environment Variables + +| Variable | Route53 | Cloudflare | Required | +|---|---|---|---| +| `DNS_PROVIDER` | `route53` | `cloudflare` | No (default: `route53`) | +| `AWS_HOSTED_ZONE_ID` | `Z0123456789` | — | Yes (Route53) | +| `AWS_ACCESS_KEY_ID` | `AKIA...` | — | Yes if no profile | +| `AWS_SECRET_ACCESS_KEY` | `...` | — | Yes if no profile | +| `AWS_PROFILE` | `myprofile` | — | Optional (alternative to keys) | +| `DDNS_HOST` | `host` | `host` | Yes | +| `DOMAIN` | `example.com` | `example.com` | Yes | +| `CNAME_LIST` | `api, www` | `api, www` | No (default: `""`) | +| `TTL` | `300` | `300` | No (default: `300`) | +| `SLEEP` | `300` | `300` | No (default: `300`) | +| `CF_ZONE_ID` | — | `abc123` | Yes (Cloudflare) | +| `CF_DNS_API_TOKEN` | — | `token...` | Yes (Cloudflare) | +| `CF_ZONE_API_TOKEN` | — | `token...` | No (defaults to write token) | +| `RUN_AS_USER` | `dns` | `dns` | No (default: `dns`) | +| `LOG_LEVEL` | `DEBUG` | `DEBUG` | No (default: `INFO`) | + +--- + +## Route53 Tests + +### Test 1: Profile-based auth (typical production) + +```bash +docker run -e DNS_PROVIDER=route53 \ + -e AWS_PROFILE=myprofile \ + -e AWS_HOSTED_ZONE_ID=Z0123456789 \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + -e CNAME_LIST=api,www \ + -v ~/.aws:/root/.aws:ro \ + your-image +``` + +**Expected:** +- Log: `Using AWS profile 'myprofile' from ~/.aws/credentials` +- Log: `AWS identity: arn:aws:iam::... (Account ...)` +- A record created/updated for `test.example.com.` +- CNAMEs created/updated for `api.example.com.` and `www.example.com.` +- Container runs indefinitely, logging "Sleeping 300 seconds" between cycles + +### Test 2: Direct key auth + +```bash +docker run -e DNS_PROVIDER=route53 \ + -e AWS_ACCESS_KEY_ID=AKIA... \ + -e AWS_SECRET_ACCESS_KEY=... \ + -e AWS_HOSTED_ZONE_ID=Z0123456789 \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +**Expected:** +- Log: `Using AWS credentials from environment variables.` +- `AWS_PROFILE` is unset from the environment before session creation +- Same record update behavior as Test 1 + +### Test 3: Default profile fallback + +```bash +docker run -e DNS_PROVIDER=route53 \ + -e AWS_HOSTED_ZONE_ID=Z0123456789 \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + -v ~/.aws:/root/.aws:ro \ + your-image +``` + +Set up `~/.aws/credentials` with a `[default]` profile and no `AWS_PROFILE` env var. + +**Expected:** +- Log: `Using default AWS profile from ~/.aws/credentials` + +--- + +## Cloudflare Tests + +### Test 4: Valid tokens + +```bash +docker run -e DNS_PROVIDER=cloudflare \ + -e CF_ZONE_ID=abc123 \ + -e CF_DNS_API_TOKEN=write-token \ + -e CF_ZONE_API_TOKEN=read-token \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +**Expected:** +- Log: Cloudflare validation succeeds (no STS call) +- A/AAAA/CNAME records managed via Cloudflare API +- No AWS-related log messages + +### Test 5: Read token omitted (falls back to write token) + +```bash +docker run -e DNS_PROVIDER=cloudflare \ + -e CF_ZONE_ID=abc123 \ + -e CF_DNS_API_TOKEN=write-token \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +**Expected:** Same behavior as Test 4; `CF_ZONE_API_TOKEN` defaults to write token value. + +--- + +## Corner Case Tests + +### Test 6: Apex CNAME rejection (Route53 only) + +CNAME_LIST labels always get `.$DOMAIN` appended, so they can never match the zone +apex. The `allows_apex_cname` guard is a safety net for future provider-level logic; +it cannot be triggered via CNAME_LIST. + +### Test 7: No external IPv6 + +Run on a host/container without IPv6 connectivity. + +**Expected:** +- `get_external_ip6()` returns `None` after exhausting all services +- Log: `No external IPv6 detected; skipping AAAA update` +- Log: `Skipping AAAA update: no external IPv6 detected` +- A record and CNAMEs still updated + +### Test 8: Record already correct (no-op cycle) + +After a successful first run, let the loop execute again without IP changes. + +**Expected:** +- Log: `A record test.example.com. already up-to-date with IP x.x.x.x` +- Log: `CNAME api.example.com. already points to test.example.com.` +- No `change_resource_record_sets` API calls (skipped by `record_is` check) + +### Test 9: Short label expansion + +```bash +-e DDNS_HOST=test -e DOMAIN=example.com -e CNAME_LIST=api,cdn +``` + +**Expected:** +- `api` → `api.example.com.` +- `cdn` → `cdn.example.com.` + +### Test 10: Labels always get DOMAIN appended + +```bash +-e DDNS_HOST=test -e DOMAIN=example.com -e CNAME_LIST=api,cdn +``` + +**Expected:** +- `api` → `api.example.com.` +- `cdn` → `cdn.example.com.` + +--- + +## Expected Failure Modes + +### Test 11: No AWS credentials at all + +```bash +docker run -e DNS_PROVIDER=route53 \ + -e AWS_HOSTED_ZONE_ID=Z0123456789 \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +No env vars, no mounted `.aws` directory. + +**Expected:** +- `RuntimeError: No valid AWS credentials found (env vars or ~/.aws/credentials)` +- Container exits with non-zero code +- No privilege drop attempted (fails during credential resolution) + +### Test 12: Missing required env var + +```bash +docker run -e DNS_PROVIDER=route53 \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +Missing `AWS_HOSTED_ZONE_ID`. + +**Expected:** +- `KeyError: 'AWS_HOSTED_ZONE_ID'` +- Container exits with traceback + +### Test 13: Invalid AWS credentials + +Mount a profile with expired/invalid keys. + +**Expected:** +- `RuntimeError: Route53 validation failed: An error occurred (InvalidClientTokenId) ...` +- Container exits (error raised in `build_provider` before the main loop) + +### Test 14: Non-existent hosted zone + +```bash +docker run -e DNS_PROVIDER=route53 \ + -e AWS_HOSTED_ZONE_ID=ZINVALID \ + -e AWS_ACCESS_KEY_ID=AKIA... \ + -e AWS_SECRET_ACCESS_KEY=... \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +**Expected:** +- `RuntimeError: Route53 validation failed: An error occurred (NoSuchHostedZone) ...` +- Container exits before entering the update loop + +### Test 15: Invalid Cloudflare token + +```bash +docker run -e DNS_PROVIDER=cloudflare \ + -e CF_ZONE_ID=abc123 \ + -e CF_DNS_API_TOKEN=invalid-token \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +**Expected:** +- `RuntimeError: Cloudflare validation failed: ...` +- Container exits before entering the update loop + +### Test 16: Missing Cloudflare required vars + +```bash +docker run -e DNS_PROVIDER=cloudflare \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +**Expected:** +- `KeyError: 'CF_ZONE_ID'` or `KeyError: 'CF_DNS_API_TOKEN'` +- Container exits with traceback + +### Test 17: Unknown provider + +```bash +docker run -e DNS_PROVIDER=bind \ + -e DDNS_HOST=test \ + -e DOMAIN=example.com \ + your-image +``` + +**Expected:** +- `SystemExit: Unknown DNS_PROVIDER=bind` +- Clean exit, no traceback + +### Test 18: Network failure during IP fetch + +Block outbound HTTPS (or set `SLEEP=1` and temporarily block network). + +**Expected:** +- tenacity retries up to 5 times with exponential backoff (2s, 4s, 8s, 10s, 10s) +- After exhaustion: `requests.RequestException: Unable to fetch external IP from any source` +- Error caught in main loop, logged, container **continues** to next cycle +- No record updates attempted + +### Test 19: API error during record check + +Temporarily revoke Route53 permissions after startup. + +**Expected (current behavior — fail-open):** +- `record_is` catches `ClientError`, logs the error, returns `True` (skip update) +- Log: `Error checking test.example.com. A: ... (AccessDenied)` +- Main loop continues; no upsert attempted for that record +- Other records (if any) in the same cycle are not affected + +### Test 20: Alias record / multi-value record + +Create an ALIAS or multi-value A record manually in the hosted zone. + +**Expected:** +- Log: `Record test.example.com. A is an alias; skipping management.` (or `has multiple values`) +- `record_is` returns `True` — no upsert attempt +- Record is left untouched + +### Test 21: Signal handling + +```bash +# After container is running: +docker kill --signal=SIGTERM +docker kill --signal=SIGINT +``` + +**Expected:** +- Log: `Received shutdown signal, exiting.` +- Container exits with code 0 +- Clean shutdown, no partial updates + +--- + +## Quick Smoke Test (no AWS/CF needed) + +Validate that the import chain and registry work without cloud credentials: + +```bash +python3 -c " +from base import _PROVIDER_REGISTRY, build_provider +from privilege import PrivilegeManager +import route53_provider +import cf_provider + +print('Registry:', _PROVIDER_REGISTRY) +assert 'route53' in _PROVIDER_REGISTRY +assert 'cloudflare' in _PROVIDER_REGISTRY +print('Registry check: OK') +" +``` + +## Debug Tips + +1. Set `LOG_LEVEL=DEBUG` to see credential file paths and per-service IP fetch failures +2. Run with `SLEEP=5` for rapid iteration during testing +3. The first cycle runs immediately; subsequent cycles wait `SLEEP` seconds +4. AWS credential file ownership/perms are set to `700/600` — verify with `ls -la /home/dns/.aws/` inside the container +5. `build_provider` does a `is cls is Route53Provider` check — adding new providers with AWS dependencies requires updating this check in `base.py` diff --git a/traefik-aws.yml b/traefik-aws.yml index a4eefbc52..b895a7341 100644 --- a/traefik-aws.yml +++ b/traefik-aws.yml @@ -53,29 +53,26 @@ services: - logs.collect=true ddns: - image: ddns-aws:local + image: ddns:local pull_policy: never build: context: ./traefik-utils restart: "unless-stopped" environment: + - DNS_PROVIDER=route53 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} - AWS_PROFILE=${AWS_PROFILE:-} - AWS_REGION=${AWS_REGION:-us-east-2} - AWS_HOSTED_ZONE_ID=${AWS_HOSTED_ZONE_ID} - - A_RECORD_NAME=${DDNS_SUBDOMAIN}.${DOMAIN} + - DDNS_HOST=${DDNS_HOST} + - DOMAIN=${DOMAIN} - CNAME_LIST=${CNAME_LIST:-} + - CF_PROXY=${CF_PROXY:-false} - TTL=${TTL:-300} - LOG_LEVEL=${LOG_LEVEL:-info} volumes: - ~/.aws:/root/.aws:ro - healthcheck: - test: ["CMD", "python", "-c", "import boto3; boto3.client('route53').list_hosted_zones()"] - interval: 1m - timeout: 5s - retries: 3 - start_period: 10s <<: *logging volumes: diff --git a/traefik-cf.yml b/traefik-cf.yml index b346d07d2..76d85b594 100644 --- a/traefik-cf.yml +++ b/traefik-cf.yml @@ -50,31 +50,23 @@ services: - logs.collect=true ddns: - image: qmcgaw/ddns-updater:${DDNS_TAG} - restart: "unless-stopped" - environment: - LOG_LEVEL: ${LOG_LEVEL:-info} - CONFIG: >- - {"settings": [ - {"provider": "cloudflare", "zone_identifier": "${CF_ZONE_ID}", - "domain": "${DDNS_SUBDOMAIN}.${DOMAIN}", "ttl": 1, "token": "${CF_DNS_API_TOKEN}", - "proxied": ${DDNS_PROXY}, "ip_version": "ipv4"}, - {"provider": "cloudflare", "zone_identifier": "${CF_ZONE_ID}", - "domain": "${DDNS_SUBDOMAIN}.${DOMAIN}", "ttl": 1, "token": "${CF_DNS_API_TOKEN}", - "proxied": ${DDNS_PROXY}, "ip_version": "ipv6"} - ]} - volumes: - - /etc/localtime:/etc/localtime:ro - <<: *logging - - curl-jq: - image: curl-jq:local + image: ddns:local pull_policy: never build: context: ./traefik-utils - dockerfile: Dockerfile.jq - restart: "no" - profiles: ["tools"] + restart: "unless-stopped" + environment: + - DNS_PROVIDER=cloudflare + - CF_ZONE_ID=${CF_ZONE_ID} + - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN} + - CF_ZONE_API_TOKEN=${CF_ZONE_API_TOKEN:-} + - DDNS_HOST=${DDNS_HOST} + - DOMAIN=${DOMAIN} + - CNAME_LIST=${CNAME_LIST:-} + - CF_PROXY=${CF_PROXY:-false} + - TTL=${TTL:-300} + - LOG_LEVEL=${LOG_LEVEL:-info} + <<: *logging volumes: certs: diff --git a/traefik-utils/Dockerfile b/traefik-utils/Dockerfile index 29f54af4c..66f4b1774 100644 --- a/traefik-utils/Dockerfile +++ b/traefik-utils/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-alpine +FROM python:3.14-alpine WORKDIR /app @@ -17,6 +17,11 @@ RUN adduser \ "${USER}" COPY --chown=${USER}:${USER} app/updater.py . +COPY --chown=${USER}:${USER} app/base.py . +COPY --chown=${USER}:${USER} app/route53_provider.py . +COPY --chown=${USER}:${USER} app/cf_provider.py . +COPY --chown=${USER}:${USER} app/provider_registry.py . +COPY --chown=${USER}:${USER} app/privilege.py . COPY --chown=${USER}:${USER} app/requirements.txt . RUN chmod 555 ./updater.py diff --git a/traefik-utils/app/.gitignore b/traefik-utils/app/.gitignore new file mode 100644 index 000000000..9f7550b1e --- /dev/null +++ b/traefik-utils/app/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +.venv diff --git a/traefik-utils/app/base.py b/traefik-utils/app/base.py new file mode 100644 index 000000000..b7eeb27e2 --- /dev/null +++ b/traefik-utils/app/base.py @@ -0,0 +1,68 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Mapping, Self + +if TYPE_CHECKING: + from privilege import PrivilegeManager + +_provider_registry: dict[str, type["DNSProvider"]] = {} + + +def register_provider(name: str, cls: type["DNSProvider"]) -> None: + _provider_registry[name.lower()] = cls + + +def build_provider( + priv: "PrivilegeManager", provider_name: str, env: Mapping[str, str] +) -> "DNSProvider": + which = provider_name.lower() + provider_class = _provider_registry.get(which) + if provider_class is None: + raise SystemExit(f"Unknown DNS_PROVIDER={which}") + + priv.setup(need_aws_config=(which == "route53")) + + provider = provider_class.from_env(env) + provider.validate() + return provider + + +class DNSProvider(ABC): + @property + @abstractmethod + def allows_apex_cname(self) -> bool: + """Whether this provider allows a CNAME at the zone apex.""" + ... + + @classmethod + @abstractmethod + def from_env(cls, env: Mapping[str, str]) -> Self: + """Construct a provider from environment variables.""" + ... + + @abstractmethod + def record_is(self, name: str, rtype: str, value: str) -> bool: + """Return True if the existing RRSet equals `value`, + or if it's an alias / a multi-value set (skip with warning). + Return False to signal an upsert is needed.""" + ... + + @abstractmethod + def upsert( + self, name: str, rtype: str, value: str, ttl: int, proxied: bool = False + ) -> bool: + """Create or update a single-value record to exactly `value` with `ttl`. + Return True if an update was performed, False if record was already correct.""" + ... + + @abstractmethod + def validate(self) -> None: + """Raise a clear exception if creds/zone are not usable.""" + ... + + +def normalize_fqdn(s: str) -> str: + """Return the canonical lowercase form of an FQDN without a trailing dot. + Route 53 API calls need the trailing dot added back; + Cloudflare does not. Use this form for all comparisons.""" + s = s.strip().lower() + return "." if s == "." else s.rstrip(".") diff --git a/traefik-utils/app/cf_provider.py b/traefik-utils/app/cf_provider.py new file mode 100644 index 000000000..02406ce4b --- /dev/null +++ b/traefik-utils/app/cf_provider.py @@ -0,0 +1,83 @@ +__lazy_modules__ = ["cloudflare"] + +import logging +from typing import Mapping + +from cloudflare import Cloudflare +from cloudflare.types.dns import DNSRecord + +from base import DNSProvider, normalize_fqdn + +logger = logging.getLogger("dns-updater") + + +class CloudflareProvider(DNSProvider): + allows_apex_cname = True + + def __init__( + self, zone_id: str, write_token: str, read_token: str | None = None + ) -> None: + self._zone = zone_id + self._cf_w = Cloudflare(api_token=write_token) + self._cf_r = Cloudflare(api_token=(read_token or write_token)) + + @classmethod + def from_env(cls, env: Mapping[str, str]) -> "CloudflareProvider": + zone_id = env["CF_ZONE_ID"] + write = env["CF_DNS_API_TOKEN"] + read = env.get("CF_ZONE_API_TOKEN") + return cls(zone_id, write, read) + + def validate(self) -> None: + try: + self._cf_r.dns.records.list(self._zone, params={"per_page": 1}) + except Exception as e: + raise RuntimeError(f"Cloudflare validation failed: {e}") from e + + def _get_record(self, name: str, rtype: str) -> DNSRecord | None: + recs = self._cf_r.dns.records.list( + self._zone, + params={"type": rtype, "name": normalize_fqdn(name), "per_page": 1}, + ) + return recs[0] if recs else None + + def record_is(self, name: str, rtype: str, value: str) -> bool: + n_name, n_value = normalize_fqdn(name), normalize_fqdn(value) + recs = self._cf_r.dns.records.list( + self._zone, params={"type": rtype, "name": n_name} + ) + + # No records exist + if not recs: + return False + + # Multiple records (skip management) + if len(recs) > 1: + logger.warning( + f"Record {n_name} {rtype} has multiple records; skipping management." + ) + return True + + # One record, check value match + have = normalize_fqdn(str(recs[0].content)) + return have == n_value + + def upsert( + self, name: str, rtype: str, value: str, ttl: int, proxied: bool = False + ) -> bool: + n_name, n_value = normalize_fqdn(name), normalize_fqdn(value) + if self.record_is(n_name, rtype, n_value): + return False + existing = self._get_record(n_name, rtype) + payload = { + "type": rtype, + "name": n_name, + "content": n_value, + "ttl": ttl, + "proxied": proxied, + } + if existing: + self._cf_w.dns.records.update(self._zone, existing.id, data=payload) + else: + self._cf_w.dns.records.create(self._zone, data=payload) + return True diff --git a/traefik-utils/app/privilege.py b/traefik-utils/app/privilege.py new file mode 100644 index 000000000..77041e6d3 --- /dev/null +++ b/traefik-utils/app/privilege.py @@ -0,0 +1,71 @@ +import os +import pwd +import grp +import shutil +import subprocess + + +class PrivilegeManager: + def __init__(self, user: str) -> None: + self._user = user + self._pw = pwd.getpwnam(user) + + def copy_aws_config(self) -> None: + src = "/root/.aws" + dst = os.path.join(self._pw.pw_dir, ".aws") + + if not os.path.exists(src): + return + + if os.path.exists(dst): + shutil.rmtree(dst) + + shutil.copytree(src, dst) + + subprocess.check_call( + ["chown", "-R", f"{self._pw.pw_uid}:{self._pw.pw_gid}", dst] + ) + + for root, dirs, files in os.walk(dst): + for d in dirs: + os.chmod(os.path.join(root, d), 0o700) + for f in files: + os.chmod(os.path.join(root, f), 0o600) + + def drop(self) -> None: + try: + if hasattr(os, "initgroups"): + os.initgroups(self._user, self._pw.pw_gid) + else: + gids = [g.gr_gid for g in grp.getgrall() if self._user in g.gr_mem] + os.setgroups(gids + [self._pw.pw_gid]) + except PermissionError: + pass + + if hasattr(os, "setresgid") and hasattr(os, "setresuid"): + os.setresgid(self._pw.pw_gid, self._pw.pw_gid, self._pw.pw_gid) + os.setresuid(self._pw.pw_uid, self._pw.pw_uid, self._pw.pw_uid) + else: + os.setgid(self._pw.pw_gid) + os.setuid(self._pw.pw_uid) + + os.environ.update( + HOME=self._pw.pw_dir, + USER=self._pw.pw_name, + LOGNAME=self._pw.pw_name, + ) + os.environ.setdefault( + "AWS_SHARED_CREDENTIALS_FILE", + f"{self._pw.pw_dir}/.aws/credentials", + ) + os.environ.setdefault( + "AWS_CONFIG_FILE", + f"{self._pw.pw_dir}/.aws/config", + ) + + os.chdir(self._pw.pw_dir) + + def setup(self, need_aws_config: bool = False) -> None: + if need_aws_config: + self.copy_aws_config() + self.drop() diff --git a/traefik-utils/app/provider_registry.py b/traefik-utils/app/provider_registry.py new file mode 100644 index 000000000..a843cb8f4 --- /dev/null +++ b/traefik-utils/app/provider_registry.py @@ -0,0 +1,9 @@ +from base import register_provider + +from route53_provider import Route53Provider +from cf_provider import CloudflareProvider + + +def register_all() -> None: + register_provider("route53", Route53Provider) + register_provider("cloudflare", CloudflareProvider) diff --git a/traefik-utils/app/requirements.in b/traefik-utils/app/requirements.in index dfd8d9534..0933da3f1 100644 --- a/traefik-utils/app/requirements.in +++ b/traefik-utils/app/requirements.in @@ -1,3 +1,4 @@ boto3 +cloudflare requests tenacity diff --git a/traefik-utils/app/requirements.txt b/traefik-utils/app/requirements.txt index 60cfb5bef..5ee5547e8 100644 --- a/traefik-utils/app/requirements.txt +++ b/traefik-utils/app/requirements.txt @@ -1,15 +1,25 @@ # -# This file is autogenerated by pip-compile with Python 3.13 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --generate-hashes --output-file=requirements.txt requirements.in +# pip-compile --generate-hashes --output-file=requirements.txt --strip-extras requirements.in # +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via pydantic +anyio==4.13.0 \ + --hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \ + --hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc + # via + # cloudflare + # httpx boto3==1.43.3 \ --hash=sha256:7c7777862ffc898f05efa566032bbabfe226dbb810e35ec11125817f128bc5c5 \ --hash=sha256:7c7777862ffc898f05efa566032bbabfe226dbb810e35ec11125817f128bc5c5 \ --hash=sha256:fb9fe51849ef2a78198d582756fc06f14f7de27f73e0fa90275d6aa4171eb4d0 \ --hash=sha256:fb9fe51849ef2a78198d582756fc06f14f7de27f73e0fa90275d6aa4171eb4d0 - # via -r app/requirements.in + # via -r requirements.in botocore==1.43.3 \ --hash=sha256:eac6da0fffccf87888ebf4d89f0b2378218a707efa748cd955b838995e944695 \ --hash=sha256:ec0769eb0f7c5034856bb406a92698dbc02a3d4be0f78a384747106b161d8ea3 @@ -19,7 +29,10 @@ botocore==1.43.3 \ certifi==2025.8.3 \ --hash=sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407 \ --hash=sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5 - # via requests + # via + # httpcore + # httpx + # requests charset-normalizer==3.4.3 \ --hash=sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91 \ --hash=sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0 \ @@ -101,16 +114,165 @@ charset-normalizer==3.4.3 \ --hash=sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c \ --hash=sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9 # via requests +cloudflare==5.0.0 \ + --hash=sha256:51a27cb954512d1bc7e5f452ad1850f734a74af6925c38339d675bf9f58866cc \ + --hash=sha256:ba21e454287dabd4cc8ba23421328627dcffd833e6c3a8661a3d5b062411e7c0 + # via -r requirements.in +distro==1.9.0 \ + --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + # via cloudflare +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via cloudflare idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 - # via requests + # via + # anyio + # httpx + # requests jmespath==1.0.1 \ --hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \ --hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe # via # boto3 # botocore +pydantic==2.13.3 \ + --hash=sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927 \ + --hash=sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d + # via cloudflare +pydantic-core==2.46.3 \ + --hash=sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba \ + --hash=sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35 \ + --hash=sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4 \ + --hash=sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505 \ + --hash=sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7 \ + --hash=sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8 \ + --hash=sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4 \ + --hash=sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37 \ + --hash=sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25 \ + --hash=sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022 \ + --hash=sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1 \ + --hash=sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7 \ + --hash=sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c \ + --hash=sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3 \ + --hash=sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb \ + --hash=sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3 \ + --hash=sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976 \ + --hash=sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c \ + --hash=sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab \ + --hash=sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa \ + --hash=sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6 \ + --hash=sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396 \ + --hash=sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c \ + --hash=sha256:3d08782c4045f90724b44c95d35ebec0d67edb8a957a2ac81d5a8e4b8a200495 \ + --hash=sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c \ + --hash=sha256:4335e87c7afa436a0dfa899e138d57a72f8aad542e2cf19c36fb428461caabd0 \ + --hash=sha256:4b068543bdb707f5d935dab765d99227aa2545ef2820935f2e5dd801795c7dbd \ + --hash=sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf \ + --hash=sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531 \ + --hash=sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5 \ + --hash=sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda \ + --hash=sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d \ + --hash=sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e \ + --hash=sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df \ + --hash=sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6 \ + --hash=sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c \ + --hash=sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13 \ + --hash=sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536 \ + --hash=sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287 \ + --hash=sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0 \ + --hash=sha256:68ef2f623dda6d5a9067ac014e406c020c780b2a358930a7e5c1b73702900720 \ + --hash=sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050 \ + --hash=sha256:6dff8cc884679df229ebc6d8eb2321ea6f8e091bc7d4886d4dc2e0e71452843c \ + --hash=sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1 \ + --hash=sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8 \ + --hash=sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0 \ + --hash=sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374 \ + --hash=sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807 \ + --hash=sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6 \ + --hash=sha256:831eb19aa789a97356979e94c981e5667759301fb708d1c0d5adf1bc0098b873 \ + --hash=sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57 \ + --hash=sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f \ + --hash=sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c \ + --hash=sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad \ + --hash=sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e \ + --hash=sha256:91249bcb7c165c2fb2a2f852dbc5c91636e2e218e75d96dfdd517e4078e173dd \ + --hash=sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23 \ + --hash=sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46 \ + --hash=sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1 \ + --hash=sha256:99421e7684a60f7f3550a1d159ade5fdff1954baedb6bdd407cba6a307c9f27d \ + --hash=sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1 \ + --hash=sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee \ + --hash=sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c \ + --hash=sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874 \ + --hash=sha256:9f247596366f4221af52beddd65af1218797771d6989bc891a0b86ccaa019168 \ + --hash=sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a \ + --hash=sha256:a35cc284c8dd7edae8a31533713b4d2467dfe7c4f1b5587dd4031f28f90d1d13 \ + --hash=sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f \ + --hash=sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a \ + --hash=sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789 \ + --hash=sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe \ + --hash=sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f \ + --hash=sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5 \ + --hash=sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943 \ + --hash=sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b \ + --hash=sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089 \ + --hash=sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b \ + --hash=sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff \ + --hash=sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67 \ + --hash=sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803 \ + --hash=sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045 \ + --hash=sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8 \ + --hash=sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346 \ + --hash=sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2 \ + --hash=sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f \ + --hash=sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687 \ + --hash=sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76 \ + --hash=sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1 \ + --hash=sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f \ + --hash=sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2 \ + --hash=sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c \ + --hash=sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018 \ + --hash=sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba \ + --hash=sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c \ + --hash=sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf \ + --hash=sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7 \ + --hash=sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47 \ + --hash=sha256:d56bdb4af1767cc15b0386b3c581fdfe659bb9ee4a4f776e92c1cd9d074000d6 \ + --hash=sha256:dcda6583921c05a40533f982321532f2d8db29326c7b95c4026941fa5074bd79 \ + --hash=sha256:dd81f6907932ebac3abbe41378dac64b2380db1287e2aa64d8d88f78d170f51a \ + --hash=sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e \ + --hash=sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a \ + --hash=sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34 \ + --hash=sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b \ + --hash=sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85 \ + --hash=sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca \ + --hash=sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f \ + --hash=sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3 \ + --hash=sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64 \ + --hash=sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22 \ + --hash=sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72 \ + --hash=sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec \ + --hash=sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d \ + --hash=sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3 \ + --hash=sha256:fa3eb7c2995aa443687a825bc30395c8521b7c6ec201966e55debfd1128bcceb \ + --hash=sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395 \ + --hash=sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb \ + --hash=sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4 \ + --hash=sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127 \ + --hash=sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56 + # via pydantic python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 @@ -127,10 +289,26 @@ six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via python-dateutil +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via cloudflare tenacity==9.1.4 \ --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \ --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a - # via -r app/requirements.in + # via -r requirements.in +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via + # cloudflare + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via pydantic urllib3==2.5.0 \ --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \ --hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc diff --git a/traefik-utils/app/route53_provider.py b/traefik-utils/app/route53_provider.py new file mode 100644 index 000000000..e97e212b4 --- /dev/null +++ b/traefik-utils/app/route53_provider.py @@ -0,0 +1,183 @@ +__lazy_modules__ = ["boto3", "botocore.exceptions"] + +import os +from contextlib import contextmanager +import logging +from typing import cast, TYPE_CHECKING, Mapping + +import boto3 +from botocore.exceptions import ClientError +from base import DNSProvider, normalize_fqdn + +if TYPE_CHECKING: + from mypy_boto3_route53.client import Route53Client + from mypy_boto3_route53.literals import RRTypeType + from mypy_boto3_route53.type_defs import ( + ChangeTypeDef, + ResourceRecordSetTypeDef, + ResourceRecordTypeDef, + ) +else: + Route53Client = object # runtime placeholder + + +logger = logging.getLogger("dns-updater") + + +def _to_route53_rr_type(rtype: str) -> RRTypeType: + """Cast generic str rtype to boto3's strict RRTypeType for type checking.""" + return cast(RRTypeType, rtype) + + +class AwsCredentialResolver: + def __init__(self, env: Mapping[str, str]) -> None: + self._env = env + + @staticmethod + @contextmanager + def _suppress_aws_profile(): + """Temporarily remove AWS_PROFILE to avoid boto3#4121 during Session creation. + Restores it after Session is created to preserve global state for other code. + """ + profile = os.environ.pop("AWS_PROFILE", None) + try: + yield + finally: + if profile is not None: + os.environ["AWS_PROFILE"] = profile + + def resolve(self) -> boto3.session.Session: + aws_access_key = self._env.get("AWS_ACCESS_KEY_ID") + aws_secret_key = self._env.get("AWS_SECRET_ACCESS_KEY") + env_creds = aws_access_key and aws_secret_key + + profile = self._env.get("AWS_PROFILE") + credentials_path = self._credentials_path() + + if env_creds: + logger.info("Using AWS credentials from environment variables.") + with self._suppress_aws_profile(): + return boto3.session.Session() + elif profile and profile.strip() and os.path.exists(credentials_path): + logger.info(f"Using AWS profile '{profile}' from {credentials_path}") + logger.debug( + f"Using AWS creds file: {os.environ.get('AWS_SHARED_CREDENTIALS_FILE', 'N/A')}" + ) + logger.debug( + f"Using AWS config file: {os.environ.get('AWS_CONFIG_FILE', 'N/A')}" + ) + return boto3.session.Session(profile_name=profile.strip()) + elif os.path.exists(credentials_path): + logger.info(f"Using default AWS profile from {credentials_path}") + logger.debug( + f"Using AWS creds file: {os.environ.get('AWS_SHARED_CREDENTIALS_FILE', 'N/A')}" + ) + logger.debug( + f"Using AWS config file: {os.environ.get('AWS_CONFIG_FILE', 'N/A')}" + ) + return boto3.session.Session() + else: + raise RuntimeError( + "No valid AWS credentials found (env vars or ~/.aws/credentials)" + ) + + def _credentials_path(self) -> str: + return os.environ.get( + "AWS_SHARED_CREDENTIALS_FILE", + os.path.expanduser("~/.aws/credentials"), + ) + + +class Route53Provider(DNSProvider): + allows_apex_cname = False + + def __init__(self, hosted_zone_id: str, session: boto3.session.Session) -> None: + self._hz = hosted_zone_id + self._session = session + self._r53 = session.client("route53") + + @classmethod + def from_env(cls, env: Mapping[str, str]) -> "Route53Provider": + hz = env["AWS_HOSTED_ZONE_ID"] + session = AwsCredentialResolver(env).resolve() + return cls(hz, session) + + def validate(self) -> None: + try: + ident = self._session.client("sts").get_caller_identity() + logger.info("AWS identity: %s (Account %s)", ident["Arn"], ident["Account"]) + self._r53.get_hosted_zone(Id=self._hz) + except Exception as e: + raise RuntimeError(f"Route53 validation failed: {e}") from e + + def record_is(self, name: str, rtype: str, value: str) -> bool: + n_name = normalize_fqdn(name) + "." + n_value = normalize_fqdn(value) + rr_type = _to_route53_rr_type(rtype) + try: + resp = self._r53.list_resource_record_sets( + HostedZoneId=self._hz, + StartRecordName=n_name, + StartRecordType=rr_type, + MaxItems="1", + ) + rrsets = resp.get("ResourceRecordSets", []) + if not rrsets: + return False + + rrset = rrsets[0] + if ( + normalize_fqdn(rrset.get("Name", "")) + "." != n_name + or rrset.get("Type") != rr_type + ): + return False + + if "AliasTarget" in rrset: + logger.warning( + f"Record {n_name} {rr_type} is an alias; skipping management." + ) + return True + + vals = [ + normalize_fqdn(rr.get("Value", "")) + for rr in rrset.get("ResourceRecords", []) + ] + if len(vals) > 1: + logger.warning( + f"Record {n_name} {rr_type} has multiple values {vals}; skipping management." + ) + return True + + return len(vals) == 1 and vals[0] == n_value + + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "Unknown") + logger.error(f"Error checking {n_name} {rr_type}: {e} ({code})") + return True + + def upsert( + self, name: str, rtype: str, value: str, ttl: int, proxied: bool = False + ) -> bool: + n_name = normalize_fqdn(name) + "." + n_value = normalize_fqdn(value) + rr_type = _to_route53_rr_type(rtype) + if self.record_is(name, rtype, value): + return False + + rr: ResourceRecordTypeDef = {"Value": n_value} + rrset: ResourceRecordSetTypeDef = { + "Name": n_name, + "Type": rr_type, + "TTL": ttl, + "ResourceRecords": [rr], + } + change: ChangeTypeDef = {"Action": "UPSERT", "ResourceRecordSet": rrset} + + self._r53.change_resource_record_sets( + HostedZoneId=self._hz, + ChangeBatch={ + "Comment": f"Auto-updated {rr_type} record for {name}", + "Changes": [change], + }, + ) + return True diff --git a/traefik-utils/app/updater.py b/traefik-utils/app/updater.py index 4ebb369c8..c68fe2044 100644 --- a/traefik-utils/app/updater.py +++ b/traefik-utils/app/updater.py @@ -1,39 +1,26 @@ -from __future__ import annotations import os -import pwd -import grp -import shutil -import subprocess -import signal import logging import time +import signal +import sys +import ipaddress +import re import requests +from types import FrameType +from typing import NoReturn + from tenacity import ( retry, wait_exponential, stop_after_attempt, retry_if_exception_type, ) -import boto3 -from botocore.exceptions import ClientError -import sys -import ipaddress -from types import FrameType -from typing import ( - TYPE_CHECKING, - NoReturn, -) -if TYPE_CHECKING: - from mypy_boto3_route53.client import Route53Client - from mypy_boto3_route53.literals import RRTypeType - from mypy_boto3_route53.type_defs import ( - ChangeTypeDef, - ResourceRecordSetTypeDef, - ResourceRecordTypeDef, - ) -else: - Route53Client = object # runtime placeholder +from base import build_provider, normalize_fqdn +from privilege import PrivilegeManager +from provider_registry import register_all + +logger = logging.getLogger("dns-updater") def setup_logger() -> logging.Logger: @@ -46,102 +33,10 @@ def setup_logger() -> logging.Logger: logger.addHandler(_handler) _level = os.getenv("LOG_LEVEL", "INFO").upper() logger.setLevel(getattr(logging, _level, logging.INFO)) - logger.propagate = False # Prevent propagation to root logger + logger.propagate = False return logger -def drop_privileges(user: str) -> None: - pw = pwd.getpwnam(user) - # Supplementary groups - try: - if hasattr(os, "initgroups"): - os.initgroups(user, pw.pw_gid) - else: - gids = [g.gr_gid for g in grp.getgrall() if user in g.gr_mem] - os.setgroups(gids + [pw.pw_gid]) - except PermissionError: - pass # container may lack CAP_SETGID - - # Drop GID/UID (use setres* when available) - if hasattr(os, "setresgid") and hasattr(os, "setresuid"): - os.setresgid(pw.pw_gid, pw.pw_gid, pw.pw_gid) - os.setresuid(pw.pw_uid, pw.pw_uid, pw.pw_uid) - else: - os.setgid(pw.pw_gid) - os.setuid(pw.pw_uid) - - os.environ.update(HOME=pw.pw_dir, USER=pw.pw_name, LOGNAME=pw.pw_name) - os.environ.setdefault( - "AWS_SHARED_CREDENTIALS_FILE", f"{pw.pw_dir}/.aws/credentials" - ) - os.environ.setdefault("AWS_CONFIG_FILE", f"{pw.pw_dir}/.aws/config") - - os.chdir(pw.pw_dir) - - -def copy_aws_config(user: str) -> None: - pw = pwd.getpwnam(user) - src = "/root/.aws" - dst = os.path.join(pw.pw_dir, ".aws") - - if not os.path.exists(src): - return # nothing to copy - - if os.path.exists(dst): - shutil.rmtree(dst) - - shutil.copytree(src, dst) - - subprocess.check_call(["chown", "-R", f"{pw.pw_uid}:{pw.pw_gid}", dst]) - - # perms: 700 for dirs, 600 for files - for root, dirs, files in os.walk(dst): - for d in dirs: - os.chmod(os.path.join(root, d), 0o700) - for f in files: - os.chmod(os.path.join(root, f), 0o600) - - -def check_credentials(user: str) -> None: - pw = pwd.getpwnam(user) - aws_access_key = os.getenv("AWS_ACCESS_KEY_ID") - aws_secret_key = os.getenv("AWS_SECRET_ACCESS_KEY") - env_creds = aws_access_key and aws_secret_key - - profile = os.getenv("AWS_PROFILE") - credentials_path = f"{pw.pw_dir}/.aws/credentials" - - # 1. Use env vars if both are present - if env_creds: - logger.info("Using AWS credentials from environment variables.") - os.unsetenv("AWS_PROFILE") - os.environ.pop("AWS_PROFILE", None) - return - - # 2. Use profile only if it's not None and not empty/whitespace - if profile and profile.strip() and os.path.exists(credentials_path): - logger.info(f"Using AWS profile '{profile}' from {credentials_path}") - logger.debug( - f"Using AWS creds file: {os.environ['AWS_SHARED_CREDENTIALS_FILE']}" - ) - logger.debug(f"Using AWS config file: {os.environ['AWS_CONFIG_FILE']}") - return - - # 3. If profile is missing/empty but credentials file exists, use default - if os.path.exists(credentials_path): - logger.info(f"Using default AWS profile from {credentials_path}") - logger.debug( - f"Using AWS creds file: {os.environ['AWS_SHARED_CREDENTIALS_FILE']}" - ) - logger.debug(f"Using AWS config file: {os.environ['AWS_CONFIG_FILE']}") - return - - # 4. Nothing found - raise RuntimeError( - "No valid AWS credentials found (env vars or ~/.aws/credentials)" - ) - - def validate_ipv4(ip: str) -> bool: try: return isinstance(ipaddress.ip_address(ip), ipaddress.IPv4Address) @@ -161,7 +56,7 @@ def validate_ipv6(ip: str) -> bool: stop=stop_after_attempt(5), retry=retry_if_exception_type(requests.RequestException), ) -def get_external_ip() -> str: +def get_external_ip4() -> str: ip_services = [ "https://ipv4.icanhazip.com", "https://checkip.amazonaws.com", @@ -208,7 +103,7 @@ def get_external_ip6() -> str | None: "https://api6.ipify.org", "https://ipv6.icanhazip.com", "https://ifconfig.co/ip", - "https://ident.me", # works on v6 if reachable via v6 + "https://ident.me", "https://myexternalip.com/raw", ] for url in ip6_services: @@ -227,97 +122,14 @@ def get_external_ip6() -> str | None: return None -def normalize_fqdn(s: str) -> str: - return s.strip().rstrip(".").lower() - - -def record_exists( - name: str, - rtype: RRTypeType, - value: str, - hosted_zone_id: str, - route53: Route53Client, -) -> bool: - try: - resp = route53.list_resource_record_sets( - HostedZoneId=hosted_zone_id, - StartRecordName=name, - StartRecordType=rtype, - MaxItems="1", - ) - records = resp.get("ResourceRecordSets", []) - if records and records[0]["Name"] == name and records[0]["Type"] == rtype: - rrset = records[0] - if "AliasTarget" in rrset: - logger.warning( - f"{rtype} {name} is an AliasTarget; skipping management." - ) - return True # don’t try to manage alias records - existing_values = [r["Value"] for r in rrset.get("ResourceRecords", [])] - if rtype == "CNAME": - ev = {normalize_fqdn(v) for v in existing_values} - return normalize_fqdn(value) in ev - return value in existing_values - except ClientError as e: - logger.error(f"Error checking existing record: {e}") - return False - - -def upsert_record( - name: str, - rtype: RRTypeType, - value: str, - ttl: int, - hosted_zone_id: str, - route53: Route53Client, -) -> None: - rr: ResourceRecordTypeDef = {"Value": value} - rrset: ResourceRecordSetTypeDef = { - "Name": name, - "Type": rtype, - "TTL": ttl, - "ResourceRecords": [rr], - } - change: ChangeTypeDef = {"Action": "UPSERT", "ResourceRecordSet": rrset} - try: - route53.change_resource_record_sets( - HostedZoneId=hosted_zone_id, - ChangeBatch={ - "Comment": f"Auto-updated {rtype} record for {name}", - "Changes": [change], - }, - ) - logger.info(f"Upserted {rtype} record: {name} -> {value}") - except ClientError as e: - logger.error(f"Failed to upsert {rtype} record {name}: {e}") - - -def build_cname_fqdn(label_or_name: str, domain: str) -> str: - """ - - 'api' -> 'api..' - - 'api.example.com' -> 'api.example.com.' - - 'api.example.com.' -> 'api.example.com.' - - '' or '.' -> '.' - Always returns a trailing-dot FQDN. - """ - n = label_or_name.strip().rstrip(".") +def build_cname_fqdn(label: str, domain: str) -> str: + n = label.strip().rstrip(".") d = domain.strip().rstrip(".") if not n: raise ValueError("Empty CNAME entry") - - # Already fully-qualified for this domain - if n == d or n.endswith("." + d): - return n + "." - - # Some other absolute name (contains a dot), just normalize trailing dot - if "." in n: - return n + "." - - # Short label: expand with domain return f"{n}.{d}." -# graceful shutdown def _shutdown(signum: int, frame: FrameType | None) -> NoReturn: logger.info("Received shutdown signal, exiting.") raise SystemExit(0) @@ -327,65 +139,70 @@ def main() -> None: signal.signal(signal.SIGTERM, _shutdown) signal.signal(signal.SIGINT, _shutdown) - USER_NAME = "dns" - copy_aws_config(USER_NAME) - drop_privileges(USER_NAME) + register_all() - check_credentials(USER_NAME) - session = boto3.session.Session() - try: - sts = session.client("sts") - ident = sts.get_caller_identity() - logger.info(f"AWS identity: {ident['Arn']} (Account {ident['Account']})") - except Exception as e: - logger.error(f"Failed to verify AWS credentials: {e}") - sys.exit(1) - route53 = session.client("route53") + user = os.getenv("RUN_AS_USER", "dns") + priv = PrivilegeManager(user) + provider_name = os.getenv("DNS_PROVIDER", "route53") + provider = build_provider(priv, provider_name, os.environ) - # Load environment variables - HOSTED_ZONE_ID = os.environ["AWS_HOSTED_ZONE_ID"] - A_RECORD_NAME = os.environ["A_RECORD_NAME"] + DDNS_HOST = os.environ["DDNS_HOST"] + DOMAIN = os.environ["DOMAIN"] CNAME_LIST = os.getenv("CNAME_LIST", "") + CF_PROXY = os.getenv("CF_PROXY", "false").strip().lower() in ("true", "1", "yes") TTL = int(os.getenv("TTL", 300)) SLEEP = int(os.getenv("SLEEP", 300)) - # Parse CNAME targets - CNAME_TARGETS = [c.strip() for c in CNAME_LIST.split(",") if c.strip()] + CNAME_PATTERN = re.compile(r"^(.+?):(proxy|noproxy)$", re.IGNORECASE) + CNAME_ENTRIES: list[tuple[str, bool | None]] = [] + for c in CNAME_LIST.split(","): + c = c.strip() + if not c: + continue + if m := CNAME_PATTERN.match(c): + CNAME_ENTRIES.append((m.group(1), m.group(2).lower() == "proxy")) + else: + CNAME_ENTRIES.append((c, None)) + + normalized_domain = normalize_fqdn(DOMAIN) + fqdn = f"{DDNS_HOST}.{normalized_domain}." - DOMAIN = ".".join(A_RECORD_NAME.split(".")[1:]) while True: try: - ip4 = get_external_ip() + ip4 = get_external_ip4() ip6 = get_external_ip6() - fqdn = A_RECORD_NAME if A_RECORD_NAME.endswith(".") else A_RECORD_NAME + "." - if not record_exists(fqdn, "A", ip4, HOSTED_ZONE_ID, route53): - upsert_record(fqdn, "A", ip4, TTL, HOSTED_ZONE_ID, route53) + did_update = provider.upsert(fqdn, "A", ip4, TTL, proxied=CF_PROXY) + if did_update: logger.info(f"Updated A record {fqdn} with IP {ip4}") else: logger.info(f"A record {fqdn} already up-to-date with IP {ip4}") if ip6: - if not record_exists(fqdn, "AAAA", ip6, HOSTED_ZONE_ID, route53): - upsert_record(fqdn, "AAAA", ip6, TTL, HOSTED_ZONE_ID, route53) + did_update = provider.upsert(fqdn, "AAAA", ip6, TTL, proxied=CF_PROXY) + if did_update: logger.info(f"Updated AAAA record {fqdn} with IP {ip6}") else: logger.info(f"AAAA record {fqdn} already up-to-date with IP {ip6}") else: logger.debug("Skipping AAAA update: no external IPv6 detected") - for cname in CNAME_TARGETS: - cname_fqdn = build_cname_fqdn(cname, DOMAIN) - # Guard: Route53 does not allow a CNAME at the zone apex - if normalize_fqdn(cname_fqdn) == normalize_fqdn(DOMAIN): + for cname_label, proxy_override in CNAME_ENTRIES: + cname_proxied = ( + proxy_override if proxy_override is not None else CF_PROXY + ) + cname_fqdn = build_cname_fqdn(cname_label, DOMAIN) + if ( + not provider.allows_apex_cname + and normalize_fqdn(cname_fqdn) == normalized_domain + ): logger.warning(f"Skipping apex CNAME for {cname_fqdn}") continue - if not record_exists( - cname_fqdn, "CNAME", fqdn, HOSTED_ZONE_ID, route53 - ): - upsert_record( - cname_fqdn, "CNAME", fqdn, TTL, HOSTED_ZONE_ID, route53 - ) + did_update = provider.upsert( + cname_fqdn, "CNAME", fqdn, TTL, proxied=cname_proxied + ) + if did_update: + logger.info(f"Updated CNAME {cname_fqdn} for {fqdn}") else: logger.info(f"CNAME {cname_fqdn} already points to {fqdn}")