From 4c0c593b34e6a69c750c348a781151c121bc59f3 Mon Sep 17 00:00:00 2001 From: chrisfu Date: Fri, 2 Jan 2026 20:30:39 -0800 Subject: [PATCH] feat(scripts): introduce DNS sync script for Samba AD and hosts management - Added `init-prole-dns.sh` to sync host records to Samba AD DNS. - Included a fallback `hosts.txt` for static entries when Postgres is unavailable. - Script supports operations such as start, stop, sync, status, and flush. - Integrated cron-based hourly sync functionality. --- etc/hosts.txt | 15 + prole-dns/init-prole-dns.sh | 532 ++++++++++++++++++++++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 etc/hosts.txt create mode 100644 prole-dns/init-prole-dns.sh diff --git a/etc/hosts.txt b/etc/hosts.txt new file mode 100644 index 0000000..4e73115 --- /dev/null +++ b/etc/hosts.txt @@ -0,0 +1,15 @@ +myrddin.prole.org 10.0.0.3 +raspberry.prole.org 10.0.0.4 +pi.prole.org 10.0.0.5 +synology.prole.org 10.0.0.203 +morgoth.prole.org 10.0.0.204 +zinfandel.prole.org 10.0.0.205 +aventage.prole.org 10.0.0.206 +retropie.prole.org 10.0.0.207 +fairyland.prole.org 10.0.0.208 +k8s.prole.org zinfandel.prole.org +mc.prole.org 73.15.20.166 +morana.prole.org 10.0.0.66 +ollama.prole.org 73.15.20.166 +svc.prole.org 73.15.20.166 +www.prole.org ghs.googlehosted.com \ No newline at end of file diff --git a/prole-dns/init-prole-dns.sh b/prole-dns/init-prole-dns.sh new file mode 100644 index 0000000..b433304 --- /dev/null +++ b/prole-dns/init-prole-dns.sh @@ -0,0 +1,532 @@ +#!/usr/bin/env bash +set -euo pipefail + +# init-prole-dns.sh +# Sync prole.org host records into Samba AD DNS using samba-tool. +# +# Host source: +# 1) Postgres (preferred when available): +# SELECT host_name, ip_address FROM prole.hosts; +# 2) Fallback file: +# /etc/prole-dns-sync/hosts.txt (one hostname per line; FQDN or short) +# +# Commands: +# start - run sync now + install hourly cron job +# stop - remove cron job +# status - show last run time, last exit, and journal messages +# sync - run one sync pass +# flush - clear local state (forces "unknown" status); does NOT delete DNS records +# list|show|details - show configured hostnames and detected DC info +# +# Options: +# -db=HOST:PORT/DBNAME default: localhost:5432/prole-db +# +# Environment overrides (optional): +# AD_DNS_ZONE default: prole.org +# AD_DNS_SERVER default: auto-detect (hostname -f) +# AD_DNS_SERVER_IP default: auto-detect via getent hosts +# PUBLIC_DNS_SERVER default: first NS of prole.org from system resolv OR "ns1.name.com" +# DRY_RUN if set to 1, prints actions without changing AD DNS + +PROG="init_prole_dns" +STATE_DIR="/var/lib/${PROG}" +STATE_FILE="${STATE_DIR}/state.env" +CRON_FILE="/etc/cron.d/${PROG}" +CONF_DIR="/etc/prole-dns-sync" +HOSTS_FILE="${CONF_DIR}/hosts.txt" +LOG_TAG="${PROG}" + +AD_DNS_ZONE="${AD_DNS_ZONE:-prole.org}" +DRY_RUN="${DRY_RUN:-0}" + +DEFAULT_DB_ENDPOINT="localhost:5432/prole-db" +DB_ENDPOINT="$DEFAULT_DB_ENDPOINT" + +mkdir -p "$STATE_DIR" "$CONF_DIR" +chmod 700 "$STATE_DIR" + +log() { + local msg="$*" + logger -t "$LOG_TAG" -- "$msg" 2>/dev/null || true + echo "$msg" +} + +die() { + log "ERROR: $*" + exit 1 +} + +need_root() { + [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Must run as root." +} + +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +detect_dc_fqdn() { + local fqdn + fqdn="$(hostname -f 2>/dev/null || true)" + [[ -n "$fqdn" ]] || die "Unable to detect DC FQDN (hostname -f failed)." + echo "$fqdn" +} + +detect_dc_ip() { + local dc_fqdn="$1" + local ip + ip="$(getent ahostsv4 "$dc_fqdn" 2>/dev/null | awk '{print $1; exit}' || true)" + [[ -n "$ip" ]] || die "Unable to detect DC IP for $dc_fqdn (getent ahostsv4 failed)." + echo "$ip" +} + +detect_public_dns_server() { + local ns="" + if have_cmd dig; then + ns="$(dig +short NS "${AD_DNS_ZONE}" 2>/dev/null | head -n1 | sed 's/\.$//' || true)" + fi + [[ -n "$ns" ]] || ns="ns1.name.com" + echo "$ns" +} + +# Parse DB endpoint of form host:port/dbname +parse_db_endpoint() { + local ep="$1" + local hostport="${ep%%/*}" + local dbname="${ep#*/}" + local host="${hostport%%:*}" + local port="${hostport#*:}" + + if [[ "$hostport" == "$host" ]]; then + # no port specified + port="5432" + fi + [[ -n "$host" && -n "$port" && -n "$dbname" ]] || return 1 + + echo "$host|$port|$dbname" +} + +psql_can_connect() { + have_cmd psql || return 1 + local parsed + parsed="$(parse_db_endpoint "$DB_ENDPOINT")" || return 1 + local host="${parsed%%|*}"; parsed="${parsed#*|}" + local port="${parsed%%|*}"; local db="${parsed##*|}" + + # Allow interactive password prompting if needed: + # We DO NOT add -w (never prompt). We add a short statement timeout. + # If auth requires a password and none is cached, this command will prompt. + psql -h "$host" -p "$port" -d "$db" -v ON_ERROR_STOP=1 -At \ + -c "SET statement_timeout='5s'; SELECT 1;" >/dev/null 2>&1 +} + +# Functions to check if a string is an IP address +is_ipv4() { + [[ $1 =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] +} + +is_ipv6() { + [[ $1 == *:* ]] +} + +is_ip() { + is_ipv4 "$1" || is_ipv6 "$1" +} + +read_hosts_file() { + [[ -f "$HOSTS_FILE" ]] || die "Missing $HOSTS_FILE and DB not available. Create it with: hostname [TAB/SPACE] ip_or_target" + grep -vE '^\s*($|#)' "$HOSTS_FILE" | awk '{print $1 "|" $2}' || true +} + +read_hosts_db() { + local parsed + parsed="$(parse_db_endpoint "$DB_ENDPOINT")" || die "Invalid -db endpoint '$DB_ENDPOINT' (expected host:port/dbname)" + local host="${parsed%%|*}"; parsed="${parsed#*|}" + local port="${parsed%%|*}"; local db="${parsed##*|}" + + # This will prompt for password if required by libpq/pg_hba. + # Output format: host_name|ip_address + psql -h "$host" -p "$port" -d "$db" -v ON_ERROR_STOP=1 -At -F '|' \ + -c "SELECT host_name, ip_address FROM prole.hosts ORDER BY host_name;" +} + +normalize_host() { + local raw="$1" + raw="${raw%%#*}" + # Trim whitespace + raw="$(echo "$raw" | xargs)" + [[ -n "$raw" ]] || return 1 + + local fqdn="$raw" + # If it's a short name, append the zone + if [[ "$raw" != *.* ]]; then + fqdn="${raw}.${AD_DNS_ZONE}" + fi + + # Validate it's within our zone + if [[ "$fqdn" != *".${AD_DNS_ZONE}" && "$fqdn" != "${AD_DNS_ZONE}" ]]; then + # We might be reading a line that has two columns, but normalize_host is called with only the first. + # If the user provided a FQDN outside the zone, it's an error for an A/CNAME record name in this script's context. + die "Host '$raw' is not within zone '${AD_DNS_ZONE}' (got fqdn '$fqdn')." + fi + + local rel="${fqdn%.$AD_DNS_ZONE}" + rel="${rel%.}" + + echo "$rel|$fqdn" +} + +dig_records() { + local dns_server="$1" + local fqdn="$2" + local a_records aaaa_records + + a_records="$(dig +short @"$dns_server" A "$fqdn" 2>/dev/null | tr '\n' ' ' | xargs || true)" + aaaa_records="$(dig +short @"$dns_server" AAAA "$fqdn" 2>/dev/null | tr '\n' ' ' | xargs || true)" + echo "$a_records|$aaaa_records" +} + +samba_query_rr() { + local dc="$1" zone="$2" name="$3" rrtype="$4" + samba-tool dns query "$dc" "$zone" "$name" "$rrtype" 2>/dev/null \ + | awk ' + BEGIN{inrec=0} + /Records:/ {inrec=1; next} + inrec && NF>=1 { print $0 } + ' \ + | tr '\n' ' ' | xargs || true +} + +samba_add_rr() { + local dc="$1" zone="$2" name="$3" rrtype="$4" value="$5" + if [[ "$DRY_RUN" == "1" ]]; then + log "[DRY_RUN] samba-tool dns add $dc $zone $name $rrtype $value" + return 0 + fi + samba-tool dns add "$dc" "$zone" "$name" "$rrtype" "$value" +} + +samba_update_rr() { + local dc="$1" zone="$2" name="$3" rrtype="$4" old="$5" new="$6" + if [[ "$DRY_RUN" == "1" ]]; then + log "[DRY_RUN] samba-tool dns update $dc $zone $name $rrtype $old $new" + return 0 + fi + samba-tool dns update "$dc" "$zone" "$name" "$rrtype" "$old" "$new" +} + +sync_rr_set() { + local dc="$1" zone="$2" name_rel="$3" fqdn="$4" rrtype="$5" desired_values="$6" + [[ -n "$desired_values" ]] || return 0 + + local existing + existing="$(samba_query_rr "$dc" "$zone" "$name_rel" "$rrtype")" + + for val in $desired_values; do + if [[ " $existing " == *" $val "* ]]; then + log "OK: $fqdn $rrtype already has $val" + else + local first_old + first_old="$(echo "$existing" | awk '{print $1}' || true)" + if [[ -n "$first_old" ]]; then + log "UPDATE: $fqdn $rrtype $first_old -> $val" + samba_update_rr "$dc" "$zone" "$name_rel" "$rrtype" "$first_old" "$val" || samba_add_rr "$dc" "$zone" "$name_rel" "$rrtype" "$val" + else + log "ADD: $fqdn $rrtype $val" + samba_add_rr "$dc" "$zone" "$name_rel" "$rrtype" "$val" + fi + fi + done +} + +write_state() { + local key="$1" val="$2" + if [[ -f "$STATE_FILE" ]] && grep -qE "^${key}=" "$STATE_FILE"; then + sed -i "s|^${key}=.*|${key}=${val}|g" "$STATE_FILE" + else + echo "${key}=${val}" >> "$STATE_FILE" + fi + chmod 600 "$STATE_FILE" +} + +load_state() { + [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" || true +} + +install_cron() { + local script_path + script_path="$(readlink -f "$0")" + cat > "$CRON_FILE" </dev/null 2>&1 +EOF + chmod 644 "$CRON_FILE" + log "Installed hourly cron job at $CRON_FILE" +} + +remove_cron() { + if [[ -f "$CRON_FILE" ]]; then + rm -f "$CRON_FILE" + log "Removed cron job $CRON_FILE" + else + log "Cron job not present ($CRON_FILE)" + fi +} + +choose_host_source() { + # Echo "db" if we can connect; otherwise "file" + if have_cmd psql; then + # We want to allow interactive prompting; connectivity test here should not block forever. + # We'll do a *non-interactive* quick check first: if it fails, fall back to file. + # (If you want forced DB prompting, run: sudo PSQL_FORCE_PROMPT=1 ... sync) + if psql_can_connect; then + echo "db" + return 0 + fi + fi + echo "file" +} + +cmd_list() { + local dc_fqdn dc_ip public_dns source + dc_fqdn="${AD_DNS_SERVER:-$(detect_dc_fqdn)}" + dc_ip="${AD_DNS_SERVER_IP:-$(detect_dc_ip "$dc_fqdn")}" + public_dns="${PUBLIC_DNS_SERVER:-$(detect_public_dns_server)}" + source="$(choose_host_source)" + + echo "AD zone : $AD_DNS_ZONE" + echo "AD DC (server) : $dc_fqdn" + echo "AD DC IP : $dc_ip" + echo "Public resolver: $public_dns" + echo "DB endpoint : $DB_ENDPOINT" + echo "Hosts file : $HOSTS_FILE" + echo "Host source : $source" + echo + + echo "Hostnames:" + if [[ "$source" == "db" ]]; then + read_hosts_db | while IFS='|' read -r host ip; do + [[ -n "${host// }" ]] || continue + local norm rel fqdn + norm="$(normalize_host "$host")" || continue + rel="${norm%%|*}" + fqdn="${norm##*|}" + if [[ -n "${ip// }" ]]; then + if is_ip "$ip"; then + echo " - $fqdn (name='$rel', ip=$ip)" + else + echo " - $fqdn (name='$rel', cname=$ip)" + fi + else + echo " - $fqdn (name='$rel', ip=)" + fi + done + else + read_hosts_file | while IFS='|' read -r host ip; do + [[ -n "${host// }" ]] || continue + local norm rel fqdn + norm="$(normalize_host "$host")" || continue + rel="${norm%%|*}" + fqdn="${norm##*|}" + if [[ -n "${ip// }" ]]; then + if is_ip "$ip"; then + echo " - $fqdn (name='$rel', ip=$ip)" + else + echo " - $fqdn (name='$rel', cname=$ip)" + fi + else + echo " - $fqdn (name='$rel', ip=)" + fi + done + fi +} + +cmd_start() { + need_root + cmd_sync + install_cron +} + +cmd_stop() { + need_root + remove_cron +} + +cmd_flush() { + need_root + rm -f "$STATE_FILE" + log "Cleared local state ($STATE_FILE). DNS records were NOT changed." +} + +cmd_status() { + load_state + echo "State file: $STATE_FILE" + echo " last_run_epoch : ${LAST_RUN_EPOCH:-}" + if [[ -n "${LAST_RUN_EPOCH:-}" ]]; then + date -d "@${LAST_RUN_EPOCH}" +" last_run_time : %F %T %Z" + else + echo " last_run_time : " + fi + echo " last_exit : ${LAST_EXIT:-}" + echo " last_source : ${LAST_SOURCE:-}" + echo " last_public_ns : ${LAST_PUBLIC_DNS:-}" + echo " last_dc : ${LAST_DC:-}" + echo " db_endpoint : ${DB_ENDPOINT}" + echo + echo "Recent logs:" + echo "----------------------------------------" + journalctl -t "$LOG_TAG" -n 50 --no-pager 2>/dev/null || \ + echo "(journalctl tag '$LOG_TAG' not available; try: sudo journalctl -n 50 | grep $LOG_TAG)" +} + +cmd_sync() { + need_root + have_cmd samba-tool || die "samba-tool not found (run this on the Samba AD DC)." + have_cmd dig || die "dig not found. Install bind9-dnsutils or dnsutils." + + local dc_fqdn dc_ip public_dns source + dc_fqdn="${AD_DNS_SERVER:-$(detect_dc_fqdn)}" + dc_ip="${AD_DNS_SERVER_IP:-$(detect_dc_ip "$dc_fqdn")}" + public_dns="${PUBLIC_DNS_SERVER:-$(detect_public_dns_server)}" + source="$(choose_host_source)" + + write_state "LAST_RUN_EPOCH" "$(date +%s)" + write_state "LAST_PUBLIC_DNS" "$public_dns" + write_state "LAST_DC" "$dc_fqdn" + write_state "LAST_SOURCE" "$source" + + log "Starting sync: zone=${AD_DNS_ZONE}, dc=${dc_fqdn} (${dc_ip}), public_dns=${public_dns}, source=${source}, db=${DB_ENDPOINT}, dry_run=${DRY_RUN}" + + local exit_code=0 + + # Helper to process a single host entry + process_host() { + local host="$1" ip="$2" + [[ -n "${host// }" ]] || return 0 + local norm rel fqdn + norm="$(normalize_host "$host")" || return 0 + rel="${norm%%|*}" + fqdn="${norm##*|}" + + # Skip zone apex + if [[ "$fqdn" == "$AD_DNS_ZONE" ]]; then + log "INFO: Skipping zone apex $fqdn" + return 0 + fi + + local a_vals="" aaaa_vals="" cname_vals="" + if [[ -n "${ip// }" ]]; then + if is_ip "$ip"; then + # If a single IP is provided, classify as v4/v6 + if is_ipv6 "$ip"; then + aaaa_vals="$ip" + else + a_vals="$ip" + fi + else + # It's a hostname, use CNAME + cname_vals="$ip" + fi + else + # No IP in entry: resolve from public DNS + local recs + recs="$(dig_records "$public_dns" "$fqdn")" + a_vals="${recs%%|*}" + aaaa_vals="${recs##*|}" + if [[ -z "$a_vals" && -z "$aaaa_vals" ]]; then + log "WARN: No A/AAAA in public DNS for $fqdn (via $public_dns); skipping" + return 0 + fi + fi + + local ret=0 + if [[ -n "$a_vals" ]]; then + if ! sync_rr_set "$dc_fqdn" "$AD_DNS_ZONE" "$rel" "$fqdn" "A" "$a_vals"; then ret=1; fi + fi + if [[ -n "$aaaa_vals" ]]; then + if ! sync_rr_set "$dc_fqdn" "$AD_DNS_ZONE" "$rel" "$fqdn" "AAAA" "$aaaa_vals"; then ret=1; fi + fi + if [[ -n "$cname_vals" ]]; then + # Samba-tool CNAME values should ideally be FQDN with trailing dot for safety, + # but let's see how it handles it. The issue says "samba-tool dns add .. cname" + if ! sync_rr_set "$dc_fqdn" "$AD_DNS_ZONE" "$rel" "$fqdn" "CNAME" "$cname_vals"; then ret=1; fi + fi + return $ret + } + + if [[ "$source" == "db" ]]; then + while IFS='|' read -r host ip; do + if ! process_host "$host" "$ip"; then exit_code=1; fi + done < <(read_hosts_db) + else + while IFS='|' read -r host ip; do + if ! process_host "$host" "$ip"; then exit_code=1; fi + done < <(read_hosts_file) + fi + + write_state "LAST_EXIT" "$exit_code" + if [[ "$exit_code" -eq 0 ]]; then + log "Sync complete: success" + else + log "Sync complete: errors occurred" + fi + return "$exit_code" +} + +usage() { + cat < + +Commands: + start Run sync now and install hourly cron job + stop Remove hourly cron job + status Show last run time, exit status, and recent logs + sync Run one sync pass now + flush Clear local state file (does not change DNS records) + list Show host list + DC info (alias: show, details) + show Alias for list + details Alias for list + +DB option: + -db=HOST:PORT/DBNAME default: ${DEFAULT_DB_ENDPOINT} + +Host list sources: + 1) If psql exists and DB is reachable: + SELECT host_name, ip_address FROM prole.hosts; + (psql may prompt for password interactively) + 2) Else: $HOSTS_FILE + +Examples: + sudo $0 sync + sudo $0 -db=localhost:5432/prole-db sync + sudo DRY_RUN=1 $0 sync + sudo $0 start + sudo $0 status +EOF +} + +main() { + # Parse -db=... and keep remaining args + local args=() + for a in "$@"; do + case "$a" in + -db=*) DB_ENDPOINT="${a#-db=}" ;; + *) args+=("$a") ;; + esac + done + + local cmd="${args[0]:-}" + case "$cmd" in + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + sync) cmd_sync ;; + flush) cmd_flush ;; + list|show|details) cmd_list ;; + ""|-h|--help|help) usage ;; + *) die "Unknown command: $cmd (try --help)" ;; + esac +} + +main "$@" \ No newline at end of file