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.
This commit is contained in:
chrisfu 2026-01-02 20:30:39 -08:00
parent b735a1df13
commit 4c0c593b34
2 changed files with 547 additions and 0 deletions

15
etc/hosts.txt Normal file
View File

@ -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

532
prole-dns/init-prole-dns.sh Normal file
View File

@ -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" <<EOF
# Managed by $PROG. Runs hourly.
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 * * * * root $script_path -db=$DB_ENDPOINT sync >/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=<lookup>)"
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=<lookup>)"
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:-<unknown>}"
if [[ -n "${LAST_RUN_EPOCH:-}" ]]; then
date -d "@${LAST_RUN_EPOCH}" +" last_run_time : %F %T %Z"
else
echo " last_run_time : <unknown>"
fi
echo " last_exit : ${LAST_EXIT:-<unknown>}"
echo " last_source : ${LAST_SOURCE:-<unknown>}"
echo " last_public_ns : ${LAST_PUBLIC_DNS:-<unknown>}"
echo " last_dc : ${LAST_DC:-<unknown>}"
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 <<EOF
Usage: $0 [-db=host:port/dbname] <command>
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 "$@"