diff --git a/WORKING.md b/WORKING.md index fb7284e..0bd51bb 100644 --- a/WORKING.md +++ b/WORKING.md @@ -19,3 +19,10 @@ inventory #307 v2, and git PFVCluster + KNEL/pfv-bms) - [x] HACS check: NOT installed (answer to founder) [#344] - [x] Midea minisplit dongle found on WiFi (c4:39:60:59:0c:01 / 192.168.1.149) → #614 updated - [x] Ticket #614 created (dynamic temp management); IaC items filed on #454; audit log #298 +- [x] Kuma: dup ICMP monitor 199 deleted; pfv-bms-ha-http (221) added, UP/200 verified [#344] +- [x] DNS perm-fix: sync-zones.sh reload-on-change (TDD, green) deployed + live-proven 75s propagation [#469] +- [x] pfv-tsys1-lan / pfv-tsys9-lan DNS records (LAN path for NUT) +- [x] UPS in HA: NUT renamed tsys1_ups/tsys9_ups, APC config entry via REST flow, sensors live (100%/Online) [#439] +- [x] Tripp Lite dropped off USB on tsys9 (physical, #372) — config ready, binds on return [#439] +- [x] SSH locked to key-only (workstation key), password path verified dead [#344] +- [x] HACS 2.0.5 installed on box (custom_components gitignored) — founder GitHub auth pending [#614] diff --git a/netinfra/dns-cluster-setup/sync-zones.sh b/netinfra/dns-cluster-setup/sync-zones.sh index 7e190d4..1011f19 100755 --- a/netinfra/dns-cluster-setup/sync-zones.sh +++ b/netinfra/dns-cluster-setup/sync-zones.sh @@ -3,13 +3,15 @@ # sync-zones.sh — rsync-based zone replication from primary to secondary # # Runs on the SECONDARY (netinfra-02). Syncs the zones/ directory from the -# primary (netinfra-01) every 60 seconds. When a zone file changes, Technitium -# detects the modification and reloads automatically. +# primary (netinfra-01) every 60 seconds. When rsync changes any zone file, +# Technitium is reloaded (container restart) so it serves the fresh zones — +# Technitium does NOT reliably auto-detect externally modified zone files, +# which left the secondary serving stale records (seen 2026-09-01 [#344]). # # This is used instead of AXFR-based zone transfer because Technitium's zone # transfer mechanism uses port 53 (standard DNS), but on the netinfra hosts # port 53 is Pi-hole and Technitium is on port 5300. rsync-based replication -# avoids the port conflict entirely. +# avoids the port conflict entirely. Native clustering tracked in [#469]. # # Install as a systemd service/timer or run via cron: # * * * * * /home/localuser/services/technitium/sync-zones.sh @@ -21,23 +23,50 @@ CONFIG_DIR="${CONFIG_DIR:-/home/localuser/services/technitium/config}" ZONE_DIR="$CONFIG_DIR/zones" LOCK_FILE="/tmp/technitium-zone-sync.lock" LOG_FILE="${LOG_FILE:-/home/localuser/services/technitium/sync.log}" +# Reload command; array form so tests can substitute a fake. +read -r -a RELOAD_CMD <<< "${RELOAD_CMD:-docker restart tsys-dns}" +export RELOAD_CMD log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >> "$LOG_FILE"; } -# Prevent overlapping runs -exec 9>"$LOCK_FILE" || exit 0 -flock -n 9 || { log "another sync is running; skipping"; exit 0; } - -mkdir -p "$ZONE_DIR" - -# rsync zones from primary. Use --temp-dir to avoid partial writes being -# picked up by Technitium, and --delete to remove zones deleted on primary. -log "Syncing zones from $PRIMARY_HOST..." -if rsync -az --delete --temp-dir=/tmp \ - "${PRIMARY_HOST}:$ZONE_DIR/" "$ZONE_DIR/" >> "$LOG_FILE" 2>&1; then +# Sync zones from the primary and reload Technitium when anything changed. +# Returns non-zero when rsync fails (reload failures are logged, not fatal). +sync_and_reload() { + local changed rc zone_count changed_count + # --temp-dir avoids partial writes; --delete removes zones deleted on + # primary; --itemize-changes reports exactly what changed. + changed=$(rsync -az --delete --itemize-changes --out-format='%n' --temp-dir=/tmp \ + "${PRIMARY_HOST}:$ZONE_DIR/" "$ZONE_DIR/" 2>> "$LOG_FILE") + rc=$? + if [ "$rc" -ne 0 ]; then + return "$rc" + fi zone_count=$(find "$ZONE_DIR" -maxdepth 1 -type f | wc -l) - log "Sync complete: $zone_count zones" -else - log "ERROR: rsync failed (rc=$?)" - exit 1 + if [ -n "$changed" ]; then + changed_count=$(printf '%s\n' "$changed" | wc -l) + log "Sync complete: $zone_count zones, $changed_count changed — reloading Technitium" + if "${RELOAD_CMD[@]}" >> "$LOG_FILE" 2>&1; then + log "Technitium reload triggered" + else + log "WARN: reload command failed (rc=$?) — records may be stale until next change" + fi + else + log "Sync complete: $zone_count zones (no changes)" + fi +} + +main() { + # Prevent overlapping runs + exec 9>"$LOCK_FILE" || exit 0 + flock -n 9 || { log "another sync is running; skipping"; exit 0; } + + mkdir -p "$ZONE_DIR" + + log "Syncing zones from $PRIMARY_HOST..." + sync_and_reload || { log "ERROR: rsync failed (rc=$?)"; exit 1; } +} + +# Allow sourcing (unit tests) without executing the sync +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main fi diff --git a/tests/unit/dns-zone-sync.sh b/tests/unit/dns-zone-sync.sh new file mode 100755 index 0000000..7216553 --- /dev/null +++ b/tests/unit/dns-zone-sync.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Unit tests for netinfra/dns-cluster-setup/sync-zones.sh [#469] +# Verifies reload-on-change behavior: Technitium restart fires only when +# rsync actually changed zone files, and never on clean syncs. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$PROJECT_ROOT/netinfra/dns-cluster-setup/sync-zones.sh" + +failed=0 + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +export LOG_FILE="$TMP/sync.log" + +# Script must exist and source cleanly (main guarded for sourcing) +if [[ -f "$SCRIPT" ]] && (source "$SCRIPT"); then + echo "✅ script exists + sources cleanly" +else + echo "❌ script missing or fails to source: $SCRIPT" + exit 1 +fi +# shellcheck disable=SC1091 +source "$SCRIPT" + +# Fake command log: record invocations of rsync / reload / find +FAKE_BIN="$TMP/bin" +mkdir -p "$FAKE_BIN" +export PATH="$FAKE_BIN:$PATH" + +make_rsync() { + # $1 = exit code, $2 = "changed" or "clean" (stdout of rsync) + cat > "$FAKE_BIN/rsync" < "$FAKE_BIN/reload" <<'EOF' +#!/bin/bash +echo "reload-fired" >> "$RELOAD_LOG" +EOF + chmod +x "$FAKE_BIN/reload" +} + +RELOAD_LOG="$TMP/reload.log" +touch "$RELOAD_LOG" +export RELOAD_LOG + +# find: minimal fake so zone counting works +mkdir -p "$FAKE_BIN/find-dir" +cat > "$FAKE_BIN/find" <<'EOF' +#!/bin/bash +# report 3 fake zone files regardless of args +for i in 1 2 3; do echo "/zones/zone$i"; done +EOF +chmod +x "$FAKE_BIN/find" + +test_reload_fires_on_change() { + make_rsync 0 "knel.net.zone" + make_reload + RELOAD_CMD=("$FAKE_BIN/reload") + export RELOAD_CMD + if sync_and_reload >/dev/null 2>&1 && [[ "$(cat "$RELOAD_LOG")" == "reload-fired" ]]; then + echo "✅ reload fires when rsync reports changed zones" + else + echo "❌ reload did not fire on changed sync" + ((++failed)) + fi + : > "$RELOAD_LOG" +} + +test_no_reload_on_clean_sync() { + make_rsync 0 "" + make_reload + RELOAD_CMD=("$FAKE_BIN/reload") + export RELOAD_CMD + if sync_and_reload >/dev/null 2>&1 && [[ ! -s "$RELOAD_LOG" ]]; then + echo "✅ no reload on clean sync" + else + echo "❌ reload fired (or errored) on clean sync" + ((++failed)) + fi +} + +test_rsync_failure_propagates() { + make_rsync 23 "" + RELOAD_CMD=("$FAKE_BIN/reload") + export RELOAD_CMD + if ! sync_and_reload >/dev/null 2>&1; then + echo "✅ rsync failure propagates non-zero rc" + else + echo "❌ rsync failure was swallowed" + ((++failed)) + fi + if [[ ! -s "$RELOAD_LOG" ]]; then + echo "✅ no reload attempted after rsync failure" + else + echo "❌ reload fired despite rsync failure" + ((++failed)) + fi +} + +test_reload_failure_does_not_break_sync() { + make_rsync 0 "knel.net.zone" + RELOAD_CMD=("/bin/false") + export RELOAD_CMD + if sync_and_reload >/dev/null 2>&1; then + echo "✅ reload failure does not fail the sync run" + else + echo "❌ reload failure failed the whole sync" + ((++failed)) + fi +} + +test_reload_fires_on_change +test_no_reload_on_clean_sync +test_rsync_failure_propagates +test_reload_failure_does_not_break_sync + +if ((failed > 0)); then + echo "❌ $failed sync-zones test(s) failed" + exit 1 +fi +echo "✅ all sync-zones tests passed"