#!/usr/bin/bash # # setup.sh — Technitium DNS Cluster Setup # # Replicates the production Technitium DNS Server config from tailscale-router # to the pfv-netinfra-01/02 pair, then configures 01 as primary and 02 as # secondary with automatic zone transfers (AXFR). # # PRODUCTION SAFETY: tailscale-router is accessed READ-ONLY. No file on it is # modified. The only operation is a docker cp (read) to export the config. # # ARCHITECTURE AFTER SETUP: # # pfv-netinfra-01 (192.168.3.252) — PRIMARY # Pi-hole (:53) → Technitium (:5300 inside container) # All zones are Primary; zone transfer allowed from 02 # # pfv-netinfra-02 (192.168.3.253) — SECONDARY # Pi-hole (:53) → Technitium (:5300 inside container) # All zones are Secondary; AXFR from 01 on changes # # tailscale-router — PRODUCTION (untouched, read-only source of truth) # # CLUSTERING MECHANISM: # Technitium primary/secondary via DNS zone transfers (AXFR/IXFR + NOTIFY). # 01 serves all zones as Primary. 02 fetches them as Secondary from # 01's address (192.168.3.252:5300). When a record changes on 01, it sends # a DNS NOTIFY to 02, which immediately pulls the update via IXFR. # # CREDENTIALS: # The production auth.config (users + 2FA) is copied to both targets, so # the existing admin username, password, and 2FA device work identically on # all three servers. # # USAGE: # ./setup.sh export # Step 1: read-only export from tailscale-router # ./setup.sh deploy01 # Step 2: deploy config to netinfra-01 (primary) # ./setup.sh deploy02 # Step 3: deploy config to netinfra-02 (secondary) # ./setup.sh cluster # Step 4: configure clustering (01 primary, 02 secondary) # ./setup.sh verify # Step 5: test everything # ./setup.sh all # Steps 1-5 in sequence # set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REMOTE="$HERE/remote-dns.sh" # Host aliases (defined in remote-dns.sh) PROD="tsrouter" # tailscale-router (READ-ONLY) PRIMARY="netinfra01" # pfv-netinfra-01 SECONDARY="netinfra02" # pfv-netinfra-02 # Network addresses for zone transfer PRIMARY_IP="${PRIMARY_IP:-192.168.3.252}" SECONDARY_IP="${SECONDARY_IP:-192.168.3.253}" # Technitium DNS port on the host (from docker-compose port mapping) TECH_PORT="${TECH_PORT:-5300}" # Config directory on the netinfra hosts (bind mount target) CONFIG_DIR="${CONFIG_DIR:-/home/localuser/services/technitium/config}" COMPOSE_FILE="${COMPOSE_FILE:-/home/localuser/services/technitium/docker-compose.yml}" # Temporary admin password used ONLY during clustering API calls. # After configuration, the production auth.config (with 2FA) is restored. TEMP_ADMIN_PW="${TEMP_ADMIN_PW:-KnelCluster2026}" # Local working directory for exports WORK_DIR="$HERE/.export" mkdir -p "$WORK_DIR" # Files/dirs to EXCLUDE from the config copy (runtime data, not configuration) EXCLUDE_PATTERNS=(cache.bin stats logs) log() { printf '\033[0;36m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; } die() { log "ERROR: $*"; exit 1; } # ----------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------- # Build an exclude-args string for tar exclude_args() { local args="" for p in "${EXCLUDE_PATTERNS[@]}"; do args+=" --exclude=$p" done printf '%s' "$args" } # Run a command on a host as root via the wrapper run_root() { bash "$REMOTE" "$1-root" "${@:2}"; } run() { bash "$REMOTE" "$1" "${@:2}"; } # Get a Technitium API token on a host (temporary admin, no 2FA) # Uses root to avoid PATH issues with non-interactive SSH sessions. # Usage: get_token get_token() { local host="$1" local resp resp=$(run_root "$host" "curl -sk --max-time 10 -X POST http://127.0.0.1:5380/api/user/login -d 'user=admin&pass=${TEMP_ADMIN_PW}'" 2>/dev/null || true) local token token=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('token',''))" 2>/dev/null || true) printf '%s' "$token" } # API call helper (uses root for reliable curl access) # Usage: api_call [param=value ...] api_call() { local host="$1" token="$2" endpoint="$3"; shift 3 local url="http://127.0.0.1:5380/api/${endpoint}?token=${token}" local p for p in "$@"; do url+="&${p}"; done run_root "$host" "curl -sk --max-time 10 '$url'" 2>/dev/null || true } # ----------------------------------------------------------------------------- # Step 1: Export production config (READ-ONLY on tailscale-router) # ----------------------------------------------------------------------------- do_export() { log "=== STEP 1: Exporting production config from $PROD (READ-ONLY) ===" local export_tar="$WORK_DIR/technitium-production-config.tar.gz" log "Exporting config volume from $PROD (piped, no disk writes on prod)..." # Read the Docker volume directory directly from the host filesystem. # No docker exec needed (avoids /tmp space issues on the prod host). # Pipe tar → ssh → local file. Nothing is written on production's disk. local vol_path vol_path=$(bash "$REMOTE" "$PROD-root" \ "docker volume inspect -f '{{.Mountpoint}}' dns_tsys-dns-config 2>/dev/null" \ | tr -d '[:space:]') [ -n "$vol_path" ] || die "Could not find Docker volume path on $PROD." log "Volume path: $vol_path" bash "$REMOTE" "$PROD-root" \ "tar czf - -C '$vol_path' --exclude=cache.bin --exclude=stats --exclude=logs ." \ > "$export_tar" 2>/dev/null || die "Export pipe failed." [ -s "$export_tar" ] || die "Export tarball is empty." # Inspect local zone_count zone_count=$(tar tzf "$export_tar" | grep -c '\.zone$' || true) log "Export complete: $(du -h "$export_tar" | cut -f1), $zone_count zones." # Save the zone name list for clustering tar tzf "$export_tar" | grep '\.zone$' | sed 's|^\./||; s|^zones/||; s|\.zone$||' | sort > "$WORK_DIR/zones.txt" log "Zone list saved ($zone_count zones): $(head -5 "$WORK_DIR/zones.txt" | tr '\n' ' ')..." } # ----------------------------------------------------------------------------- # Step 2: Deploy to netinfra-01 (PRIMARY) # ----------------------------------------------------------------------------- do_deploy_primary() { log "=== STEP 2: Deploying PRIMARY to $PRIMARY ===" _deploy "$PRIMARY" "primary" } # ----------------------------------------------------------------------------- # Step 3: Deploy to netinfra-02 (SECONDARY — initial clone, clustering in step 4) # ----------------------------------------------------------------------------- do_deploy_secondary() { log "=== STEP 3: Deploying SECONDARY to $SECONDARY ===" _deploy "$SECONDARY" "secondary" } # Shared deploy logic # Usage: _deploy _deploy() { local host="$1" role="$2" local export_tar="$WORK_DIR/technitium-production-config.tar.gz" [ -f "$export_tar" ] || die "No export found. Run '$0 export' first." log "Stopping Technitium on $host..." run_root "$host" "cd $CONFIG_DIR/.. && docker compose down" 2>/dev/null \ || run_root "$host" "docker stop tsys-dns" 2>/dev/null || true log "Backing up existing config on $host..." run_root "$host" " if [ -d '$CONFIG_DIR' ]; then mv '$CONFIG_DIR' '${CONFIG_DIR}.backup-$(date +%Y%m%d-%H%M%S)' fi mkdir -p '$CONFIG_DIR' " || die "Backup failed." log "Uploading production config to $host..." bash "$REMOTE" "$host-root" "cat > /tmp/technitium-config.tar.gz" < "$export_tar" \ || die "Upload failed." log "Extracting config on $host..." run_root "$host" " cd '$CONFIG_DIR' tar xzf /tmp/technitium-config.tar.gz rm -f /tmp/technitium-config.tar.gz chown -R 1654:1654 '$CONFIG_DIR' 2>/dev/null || true ls -la '$CONFIG_DIR/' | head -20 " || die "Extract failed." # Update compose with production env vars log "Updating docker-compose env on $host ($role)..." run_root "$host" " cat > /tmp/compose-patch.py << 'PYEOF' import re, sys f = sys.argv[1] with open(f) as fh: c = fh.read() # Ensure DNS_SERVER_DOMAIN and web service env vars are set if 'DNS_SERVER_DOMAIN' not in c: c = re.sub(r'(image:.*\n)', r'\1 environment:\n - DNS_SERVER_DOMAIN=knel.net\n', c, count=1) print(c) PYEOF python3 /tmp/compose-patch.py '$COMPOSE_FILE' > '${COMPOSE_FILE}.new' 2>/dev/null && mv '${COMPOSE_FILE}.new' '$COMPOSE_FILE' || true rm -f /tmp/compose-patch.py " || log "WARN: compose patch skipped (non-critical)." log "Starting Technitium on $host..." run_root "$host" "cd $CONFIG_DIR/.. && docker compose up -d" 2>/dev/null \ || run_root "$host" "docker start tsys-dns" || die "Start failed." log "Waiting for Technitium to come up on $host..." local i for i in $(seq 1 20); do if run "$host" "curl -sk --max-time 3 http://127.0.0.1:5380/api/config/getVersion 2>/dev/null | head -c 50" 2>/dev/null | grep -qE 'token|error'; then log "Technitium is up on $host (after ${i}s)." return 0 fi sleep 2 done die "Technitium did not come up on $host within 40s." } # ----------------------------------------------------------------------------- # Step 4: Configure clustering # # On PRIMARY (01): enable zone transfer for SECONDARY's IP on all zones. # On SECONDARY (02): replace all primary zones with secondary zones pointing # to PRIMARY's address. Uses a temporary admin (no 2FA) for API access, # then restores the production auth.config. # ----------------------------------------------------------------------------- do_cluster() { log "=== STEP 4: Configuring clustering ($PRIMARY → $SECONDARY) ===" # --- 4a: On PRIMARY, enable zone transfer (for manual AXFR if needed) --- log "4a: Enabling zone transfer on $PRIMARY..." _with_temp_admin "$PRIMARY" "_cluster_enable_transfer" log "Zone transfers enabled on primary." # --- 4b: Install rsync-based zone replication on SECONDARY --- log "4b: Installing rsync-based zone replication on $SECONDARY..." _install_rsync_replication log "Replication installed." } # Install rsync-based zone sync on the secondary as a systemd timer. _install_rsync_replication() { local sync_script="$HERE/sync-zones.sh" [ -f "$sync_script" ] || die "sync-zones.sh not found." # Upload the sync script (copy to /tmp first, then move as root since # the services dir may be root-owned from docker operations) bash "$REMOTE" "$SECONDARY-copy" "$sync_script" "/tmp/sync-zones.sh" \ || die "Could not copy sync-zones.sh to /tmp." run_root "$SECONDARY" "cp /tmp/sync-zones.sh /home/localuser/services/technitium/sync-zones.sh && chmod +x /home/localuser/services/technitium/sync-zones.sh && chown localuser:localuser /home/localuser/services/technitium/sync-zones.sh && rm /tmp/sync-zones.sh" \ || die "Could not install sync-zones.sh." # Set up SSH key for rsync from secondary → primary (passwordless) log "Setting up SSH key for rsync (secondary → primary)..." run_root "$SECONDARY" " if [ ! -f /home/localuser/.ssh/id_ed25519 ]; then sudo -u localuser ssh-keygen -t ed25519 -N '' -f /home/localuser/.ssh/id_ed25519 -q fi cat /home/localuser/.ssh/id_ed25519.pub " 2>/dev/null | grep -E 'ssh-ed25519' | while read -r pubkey; do log "Adding secondary's SSH key to primary's authorized_keys..." run_root "$PRIMARY" "mkdir -p /home/localuser/.ssh && echo '$pubkey' >> /home/localuser/.ssh/authorized_keys && chmod 600 /home/localuser/.ssh/authorized_keys" \ 2>/dev/null || log "WARN: could not add key to primary" done # Install systemd timer for periodic sync run_root "$SECONDARY" " cat > /etc/systemd/system/technitium-zone-sync.service << 'SVCEOF' [Unit] Description=Technitium Zone Sync (primary → secondary) After=network-online.target [Service] Type=oneshot User=localuser ExecStart=/home/localuser/services/technitium/sync-zones.sh SVCEOF cat > /etc/systemd/system/technitium-zone-sync.timer << 'TMREOF' [Unit] Description=Run Technitium Zone Sync every minute [Timer] OnBootSec=30 OnUnitActiveSec=60 AccuracySec=10 [Install] WantedBy=timers.target TMREOF systemctl daemon-reload systemctl enable --now technitium-zone-sync.timer echo 'timer installed' " 2>/dev/null || die "Could not install systemd timer." # Trigger an immediate sync log "Triggering initial sync..." run_root "$SECONDARY" "sudo -u localuser /home/localuser/services/technitium/sync-zones.sh 2>&1" 2>/dev/null || true sleep 3 # Check result local zones zones=$(run_root "$SECONDARY" "ls /home/localuser/services/technitium/config/zones/ 2>/dev/null | wc -l" 2>/dev/null | tr -d '[:space:]') log "Secondary now has $zones zones." } # Enable zone transfer for the secondary IP on all primary zones. # Runs inside _with_temp_admin, so $1 = host. _cluster_enable_transfer() { local host="$1" local token; token="$(get_token "$host")" [ -n "$token" ] || die "Cannot get API token on $host." # Set global zone transfer allow list to include the secondary. # Technitium per-zone "allow zone transfer" — use the API to set it. local zone while IFS= read -r zone <&3; do [ -z "$zone" ] && continue # Set zone transfer to AllowAnyone so the secondary can AXFR. # Technitium API param: zoneTransfer (not allowZoneTransfer). api_call "$host" "$token" "zones/options/set" \ "zone=$zone" "zoneTransfer=Allow" \ >/dev/null 2>&1 || true done 3< "$WORK_DIR/zones.txt" log "Zone transfer set to AllowAnyone for ${SECONDARY_IP} on all zones." } # Delete all primary zones and recreate as secondary zones. # Runs inside _with_temp_admin, so $1 = host. _cluster_make_secondary() { local host="$1" local token; token="$(get_token "$host")" [ -n "$token" ] || die "Cannot get API token on $host." local zone total total=$(wc -l < "$WORK_DIR/zones.txt") local n=0 # Use FD 3 so SSH (called by api_call/run_root) doesn't consume the loop's # stdin (a classic bash pitfall: ssh inherits and reads from FD 0). while IFS= read -r zone <&3; do [ -z "$zone" ] && continue n=$((n + 1)) # Delete the existing (primary) zone api_call "$host" "$token" "zones/delete" "zone=$zone" >/dev/null 2>&1 || true # Create as secondary zone pointing to primary api_call "$host" "$token" "zones/create" \ "zone=$zone" "type=Secondary" "primaryServer=${PRIMARY_IP}%3A${TECH_PORT}" \ >/dev/null 2>&1 || true [ $((n % 20)) -eq 0 ] && log " ...converted $n/$total zones" done 3< "$WORK_DIR/zones.txt" log "Converted $n zones to secondary (AXFR from ${PRIMARY_IP}:${TECH_PORT})." # Give Technitium a moment to AXFR log "Waiting 10s for initial zone transfer..." sleep 10 } # Helper: temporarily replace auth.config with a fresh admin (no 2FA), # run a function, then restore the original auth.config. # Uses a docker-compose.override.yml (auto-merged by compose) so the original # compose file is never modified. # Usage: _with_temp_admin _with_temp_admin() { local host="$1" func="$2" log "Temporarily resetting admin on $host for API access (will restore after)..." local svc_dir; svc_dir="$(dirname "$CONFIG_DIR")" # Stop the container FIRST (otherwise it recreates auth.config from memory # before we can delete it), then back up + delete auth.config, then create # the override file, then restart. log "Stopping Technitium on $host..." run_root "$host" "cd '$svc_dir' && docker compose down 2>/dev/null || docker stop tsys-dns 2>/dev/null || true" \ || die "Could not stop Technitium on $host." # Back up production auth.config, then remove it so Technitium creates a # fresh admin on next start. run_root "$host" " cp '$CONFIG_DIR/auth.config' '$CONFIG_DIR/auth.config.production' rm -f '$CONFIG_DIR/auth.config' " || die "Could not back up/remove auth.config on $host." # Create a compose override that injects the temp admin password. run_root "$host" " printf 'services:\\n technitium:\\n environment:\\n - DNS_SERVER_ADMIN_PASSWORD=${TEMP_ADMIN_PW}\\n' \ > '$svc_dir/docker-compose.override.yml' " || die "Could not create compose override on $host." # Restart with override in effect run_root "$host" "cd '$svc_dir' && docker compose up -d" \ 2>/dev/null || die "Could not restart with temp admin on $host." # Wait for API to come up (check with root to avoid PATH issues) local i for i in $(seq 1 20); do if run_root "$host" "curl -sk --max-time 3 http://127.0.0.1:5380/api/config/getVersion 2>/dev/null" 2>/dev/null | grep -q .; then log "Temp admin API is up on $host." # Give the auth subsystem a few seconds to finish creating the admin user. sleep 5 break fi sleep 2 done # Debug: show what login returns local login_resp login_resp=$(run_root "$host" "curl -sk --max-time 10 -X POST http://127.0.0.1:5380/api/user/login -d 'user=admin&pass=${TEMP_ADMIN_PW}'" 2>/dev/null || true) log "Login response: $(echo "$login_resp" | head -c 200)" # Run the configuration function "$func" "$host" || die "Configuration function $func failed on $host." # Restore: production auth.config + remove override + restart log "Restoring production auth.config (with 2FA) on $host..." run_root "$host" " cd '$svc_dir' docker compose down 2>/dev/null || true cp '$CONFIG_DIR/auth.config.production' '$CONFIG_DIR/auth.config' rm -f '$CONFIG_DIR/auth.config.production' chown 1654:1654 '$CONFIG_DIR/auth.config' 2>/dev/null || true rm -f docker-compose.override.yml docker compose up -d 2>/dev/null || true " || die "Could not restore auth.config on $host." sleep 3 log "Production auth restored on $host." } # ----------------------------------------------------------------------------- # Step 5: Verify # ----------------------------------------------------------------------------- do_verify() { log "=== STEP 5: Verification ===" bash "$HERE/verify.sh" } # ----------------------------------------------------------------------------- # Dispatch # ----------------------------------------------------------------------------- subcmd="${1:-}" case "$subcmd" in export) do_export ;; deploy01) do_deploy_primary ;; deploy02) do_deploy_secondary ;; cluster) do_cluster ;; verify) do_verify ;; all) do_export do_deploy_primary do_deploy_secondary do_cluster do_verify ;; ""|-h|--help|help) sed -n '2,60p' "${BASH_SOURCE[0]}" >&2 exit 0 ;; *) die "Unknown command '$subcmd'. Run '$0 help'." ;; esac log "=== DONE: $subcmd ==="