diff --git a/Containerfile b/Containerfile index 6ab35a0..8e63002 100644 --- a/Containerfile +++ b/Containerfile @@ -2,13 +2,18 @@ FROM docker.io/library/python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PYTHONPATH=/app + PYTHONPATH=/app \ + DNS_SYNC_STATE_DB=/var/lib/dns-sync/state.sqlite3 \ + DNS_SYNC_PLUGIN_PATH=/plugins RUN pip install --no-cache-dir requests PyYAML WORKDIR /app + COPY dns_sync /app/dns_sync COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod 0755 /usr/local/bin/entrypoint.sh + +RUN mkdir -p /var/lib/dns-sync /plugins \ + && chmod 0755 /usr/local/bin/entrypoint.sh ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md index c23fa4e..031c69d 100644 --- a/README.md +++ b/README.md @@ -26,31 +26,87 @@ Plugins are convention-loaded from environment variables. No registry edit is ne - `PROXY=npm` loads `dns_sync.plugins.sources.npm.Plugin` - `DNS_PROVIDER=verizon` loads `dns_sync.plugins.providers.verizon.Plugin` -## Current plugins +## Plugins -Sources: `npm`, `file` +The generic reconciliation engine uses adapter plugins. -Providers: `verizon` +Built-in adapters: + +- `npm` - Nginx Proxy Manager proxy hosts and certificates + +External adapters are loaded from `DNS_SYNC_PLUGIN_PATH` and must also be +explicitly enabled through `ENABLED_PLUGINS`. + +Example: + +```text +ENABLED_PLUGINS=npm,verizon +DNS_SYNC_PLUGIN_PATH=/plugins +``` + +The Verizon adapter lives under `external_plugins/verizon` and is deliberately +not copied into the dns-sync container image. The external plugin directory is +mounted read-only at runtime. + +## Container runtime + +The container periodically executes the generic reconciliation engine: + +```text +python -m dns_sync reconcile +``` + +The default container mode is read-only. + +```text +SYNC_INTERVAL=1 +DNS_SYNC_APPLY=false +DNS_SYNC_STATE_DB=/var/lib/dns-sync/state.sqlite3 +DNS_SYNC_PLUGIN_PATH=/plugins +ENABLED_PLUGINS=npm,verizon +``` + +`SYNC_INTERVAL` is expressed in minutes. The interval begins after each +reconciliation completes, so runs do not overlap. + +To permit planned changes to be applied and verified: + +```text +DNS_SYNC_APPLY=true +``` + +An optional mutation budget can be specified: + +```text +DNS_SYNC_MAX_CHANGES=10 +``` + +When `DNS_SYNC_APPLY=false`, reconciliation still performs discovery, +normalization, desired-state reconciliation, planning, and state persistence, +but no endpoint mutations are executed. + +The SQLite database should be stored on persistent storage. + +External plugins should be mounted read-only at `/plugins`. ## Main environment ```text -PROXY=npm -PROXY_IP=192.168.1.101 +NPM_ENDPOINTS=192.168.1.3,192.168.1.4,192.168.1.5 PROXY_API_PORT=81 PROXY_API_SCHEME=http -DNS_PROVIDER=verizon +DNS_TARGET_IP=192.168.1.101 + +VERIZON_ENDPOINTS=192.168.1.1 + DOMAIN_MATCHING=false DOMAIN=example.com -SYNC_INTERVAL=1 + NPM_IDENTITY=... NPM_SECRET=... ROUTER_PASSWORD=... ``` -`DNS_TARGET_IP` optionally overrides the default DNS destination, which is `PROXY_IP`. - - ## Domain filtering Filtering is performed by the central engine after source discovery and before diff --git a/dns-sync.container b/dns-sync.container index ea94e39..b1e9eca 100644 --- a/dns-sync.container +++ b/dns-sync.container @@ -1,5 +1,5 @@ [Unit] -Description=Reverse Proxy DNS Sync +Description=DNS Sync Reconciliation Service Wants=network-online.target After=network-online.target @@ -7,20 +7,30 @@ After=network-online.target Image=localhost/dns-sync:latest ContainerName=dns-sync -Environment=PROXY=npm -Environment=PROXY_IP=192.168.1.101 +Environment=ENABLED_PLUGINS=npm,verizon +Environment=DNS_SYNC_PLUGIN_PATH=/plugins +Environment=DNS_SYNC_STATE_DB=/var/lib/dns-sync/state.sqlite3 + +Environment=NPM_ENDPOINTS=192.168.1.3,192.168.1.4,192.168.1.5 +Environment=DNS_TARGET_IP=192.168.1.101 Environment=PROXY_API_PORT=81 Environment=PROXY_API_SCHEME=http -Environment=DNS_PROVIDER=verizon + +Environment=VERIZON_ENDPOINTS=192.168.1.1 Environment=DOMAIN_MATCHING=false Environment=DOMAIN=loxthelion.com + Environment=SYNC_INTERVAL=1 +Environment=DNS_SYNC_APPLY=false Environment=NPM_IDENTITY=davidfolkesmd@gmail.com Secret=dns-sync-npm-secret,type=env,target=NPM_SECRET Secret=dns-sync-router-password,type=env,target=ROUTER_PASSWORD +Volume=dns-sync-state:/var/lib/dns-sync:Z +Volume=./external_plugins:/plugins:ro,Z + [Service] Restart=on-failure RestartSec=30 diff --git a/entrypoint.sh b/entrypoint.sh index 0a94b59..bd42a44 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,17 +2,44 @@ set -u INTERVAL_MINUTES="${SYNC_INTERVAL:-1}" +APPLY_MODE="${DNS_SYNC_APPLY:-false}" +STATE_DB="${DNS_SYNC_STATE_DB:-/var/lib/dns-sync/state.sqlite3}" +MAX_CHANGES="${DNS_SYNC_MAX_CHANGES:-}" + case "$INTERVAL_MINUTES" in ''|*[!0-9]*) echo "ERROR: SYNC_INTERVAL must be an integer number of minutes" >&2 exit 1 ;; esac + if [ "$INTERVAL_MINUTES" -lt 1 ]; then echo "ERROR: SYNC_INTERVAL must be at least 1 minute" >&2 exit 1 fi +case "$(printf '%s' "$APPLY_MODE" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) + APPLY_MODE=true + ;; + 0|false|no|off) + APPLY_MODE=false + ;; + *) + echo "ERROR: DNS_SYNC_APPLY must be true or false" >&2 + exit 1 + ;; +esac + +if [ -n "$MAX_CHANGES" ]; then + case "$MAX_CHANGES" in + *[!0-9]*) + echo "ERROR: DNS_SYNC_MAX_CHANGES must be a non-negative integer" >&2 + exit 1 + ;; + esac +fi + INTERVAL_SECONDS=$((INTERVAL_MINUTES * 60)) sleep_pid="" @@ -21,22 +48,45 @@ shutdown() { [ -z "$sleep_pid" ] || kill "$sleep_pid" 2>/dev/null || true exit 0 } + trap shutdown INT TERM +run_reconcile() { + if [ "$APPLY_MODE" = true ]; then + if [ -n "$MAX_CHANGES" ]; then + python -m dns_sync reconcile \ + --state-db "$STATE_DB" \ + --apply \ + --max-changes "$MAX_CHANGES" + else + python -m dns_sync reconcile \ + --state-db "$STATE_DB" \ + --apply + fi + else + python -m dns_sync reconcile \ + --state-db "$STATE_DB" + fi +} + echo "DNS sync service started" echo "Sync interval: ${INTERVAL_MINUTES} minute(s)" -echo "Source plugin: ${PROXY:-npm}" -echo "DNS provider: ${DNS_PROVIDER:-verizon}" +echo "Reconciliation mode: $([ "$APPLY_MODE" = true ] && echo APPLY || echo READ-ONLY)" +echo "State database: ${STATE_DB}" +echo "Enabled plugins: ${ENABLED_PLUGINS:-npm}" +echo "External plugin path: ${DNS_SYNC_PLUGIN_PATH:-/plugins}" while :; do echo - echo "=== DNS sync: $(date -Iseconds) ===" - if python -m dns_sync sync; then - echo "Sync run completed successfully" + echo "=== DNS reconciliation: $(date -Iseconds) ===" + + if run_reconcile; then + echo "Reconciliation run completed successfully" else rc=$? - echo "ERROR: Sync run failed with exit code ${rc}" >&2 + echo "ERROR: Reconciliation run failed with exit code ${rc}" >&2 fi + sleep "$INTERVAL_SECONDS" & sleep_pid=$! wait "$sleep_pid" || true