#!/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/knoe-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/knoe-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" --use-kerberos=required 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 --use-kerberos=required" return 0 fi local out if out=$(samba-tool dns add "$dc" "$zone" "$name" "$rrtype" "$value" --use-kerberos=required 2>&1); then return 0 else if [[ "$out" == *"WERR_DNS_ERROR_RECORD_ALREADY_EXISTS"* ]]; then log "INFO: Record $name $rrtype $value exists" return 0 fi echo "$out" >&2 return 1 fi } 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 --use-kerberos=required" return 0 fi local out if out=$(samba-tool dns update "$dc" "$zone" "$name" "$rrtype" "$old" "$new" --use-kerberos=required 2>&1); then return 0 else if [[ "$out" == *"WERR_DNS_ERROR_RECORD_ALREADY_EXISTS"* ]]; then log "INFO: Record $name $rrtype $new exists" return 0 fi echo "$out" >&2 return 1 fi } 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}" # Verify Kerberos ticket if running as root via sudo (common case) if [[ -z "${KRB5CCNAME:-}" ]]; then if [[ -n "${SUDO_UID:-}" ]]; then local user_candidate="FILE:/tmp/krb5cc_${SUDO_UID}" if [[ -f "${user_candidate#FILE:}" ]]; then # Try to use it, but if it's not readable by root (Operation not permitted), # we might need to tell the user to kinit as root or fix permissions. if [[ -r "${user_candidate#FILE:}" ]]; then export KRB5CCNAME="$user_candidate" log "INFO: Automatically using Kerberos cache from sudoer: $KRB5CCNAME" else log "WARN: Found Kerberos cache at ${user_candidate}, but it is not readable by root. Try: sudo kinit administrator@${AD_DNS_ZONE^^}" fi fi fi fi # If KRB5CCNAME is set but not readable, it will fail later. if [[ -n "${KRB5CCNAME:-}" && ! -r "${KRB5CCNAME#FILE:}" ]]; then log "WARN: Kerberos cache ${KRB5CCNAME} is not readable by current user ($(id -u)). samba-tool will likely fail." log "HINT: Try running 'kinit' as root, or use 'sudo kinit administrator@${AD_DNS_ZONE^^}'" fi # If KRB5CCNAME not set, check if default cache exists (for root kinit case) if [[ -z "${KRB5CCNAME:-}" ]]; then local default_cache="/tmp/krb5cc_$(id -u)" if [[ ! -f "$default_cache" ]]; then log "HINT: No Kerberos credentials found. If samba-tool fails, run: sudo -E KRB5CCNAME=/tmp/krb5cc_\$(id -u) $0 sync" fi fi 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 kinit administrator@PROLE.ORG && sudo $0 sync sudo -E $0 sync (preserves Kerberos ticket environment) 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 "$@"