#!/usr/bin/env bash # ============================================================ # set-linux-dns.sh # ============================================================ # Remotely executed via SSH (piped through stdin via plink/ssh). # Detects Linux distro, changes DNS to new DC IP, tracks every # modified file (base64-encoded before/after content), validates # resolution, and outputs structured key=value lines for the # PowerShell dispatcher (Invoke-LinuxDns.ps1) to parse. # # Usage (called by PowerShell dispatcher): # plink user@host -pw pass "sudo bash -s" < set-linux-dns.sh # plink user@host -pw pass "sudo bash -s -- --dry-run" < set-linux-dns.sh # # Arguments: # --dry-run Read-only mode. No files modified, no services restarted. # --new-dns Override new DNS IP (default: passed via env NEW_DNS) # --domain Override validation domain (default: passed via env DNS_DOMAIN) # ============================================================ set -euo pipefail # ── Argument parsing ───────────────────────────────────────────────────────── DRY_RUN=false NEW_DNS="${NEW_DNS:-10.35.40.1}" DNS_DOMAIN="${DNS_DOMAIN:-corp.local}" while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true ;; --new-dns) NEW_DNS="$2"; shift ;; --domain) DNS_DOMAIN="$2"; shift ;; *) ;; esac shift done # ── Output helpers ─────────────────────────────────────────────────────────── # All output lines are key=value for easy PowerShell parsing. # Multiline values are base64-encoded. output_line() { printf '%s=%s\n' "$1" "$2"; } b64_encode() { # base64 with no line wrapping — compatible with GNU and BSD base64 if base64 --version 2>/dev/null | grep -q GNU; then printf '%s' "$1" | base64 -w 0 else printf '%s' "$1" | base64 | tr -d '\n' fi } b64_file() { # base64-encode a file's content if base64 --version 2>/dev/null | grep -q GNU; then base64 -w 0 < "$1" else base64 < "$1" | tr -d '\n' fi } # ── Distro detection ───────────────────────────────────────────────────────── if [[ ! -f /etc/os-release ]]; then output_line "STATUS" "failed" output_line "ERROR" "Cannot detect distro: /etc/os-release not found" exit 1 fi source /etc/os-release DISTRO_ID="${ID:-unknown}" DISTRO_VERSION="${VERSION_ID:-unknown}" DISTRO_PRETTY="${PRETTY_NAME:-$DISTRO_ID $DISTRO_VERSION}" output_line "DISTRO" "$DISTRO_PRETTY" output_line "DISTRO_ID" "$DISTRO_ID" output_line "DRY_RUN" "$DRY_RUN" # ── Capture current /etc/resolv.conf as reference ──────────────────────────── RESOLV_CONF_B64="" if [[ -f /etc/resolv.conf ]]; then RESOLV_CONF_B64=$(b64_file /etc/resolv.conf) fi # ── Detect current DNS IPs from resolv.conf ────────────────────────────────── OLD_DNS_LIST=$(grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | tr '\n' ',' | sed 's/,$//') if [[ -z "$OLD_DNS_LIST" ]]; then OLD_DNS_LIST="unknown" fi output_line "OLD_DNS" "$OLD_DNS_LIST" output_line "NEW_DNS" "$NEW_DNS" # ── File tracking state ────────────────────────────────────────────────────── FILE_COUNT=0 declare -a MODIFIED_FILES=() declare -a FILES_BEFORE=() declare -a FILES_AFTER=() SERVICE_RESTARTED="" # Helper: capture file before modification capture_before() { local filepath="$1" local idx="$2" MODIFIED_FILES[$idx]="$filepath" if [[ -f "$filepath" ]]; then FILES_BEFORE[$idx]=$(b64_file "$filepath") else FILES_BEFORE[$idx]=$(b64_encode "FILE_NOT_FOUND") fi } # Helper: capture file after modification capture_after() { local filepath="$1" local idx="$2" if [[ -f "$filepath" ]]; then FILES_AFTER[$idx]=$(b64_file "$filepath") else FILES_AFTER[$idx]=$(b64_encode "FILE_NOT_FOUND_AFTER") fi } # ── Main DNS change logic — branched per distro ────────────────────────────── # ──────────────────────────────────────────────────────────────────────────── # UBUNTU 22.04 / 24.04 # Primary: /etc/systemd/resolved.conf # Secondary: /etc/netplan/*.yaml (if nameservers hardcoded) # ──────────────────────────────────────────────────────────────────────────── change_dns_ubuntu() { local changed_resolved=false local changed_netplan=false # ── Layer 1: systemd-resolved.conf ─────────────────────────────────────── local resolved_conf="/etc/systemd/resolved.conf" # Ensure the file exists — create a minimal one if missing if [[ ! -f "$resolved_conf" ]]; then if [[ "$DRY_RUN" == "false" ]]; then mkdir -p "$(dirname "$resolved_conf")" printf '[Resolve]\n' > "$resolved_conf" fi fi local idx=$FILE_COUNT capture_before "$resolved_conf" "$idx" if [[ "$DRY_RUN" == "false" ]]; then # Remove existing DNS= and FallbackDNS= lines, then set new values if grep -q '^\[Resolve\]' "$resolved_conf"; then # File has [Resolve] section — update in place sed -i '/^DNS=/d' "$resolved_conf" sed -i '/^FallbackDNS=/d' "$resolved_conf" sed -i '/^\[Resolve\]/a DNS='"$NEW_DNS" "$resolved_conf" else # Append [Resolve] section printf '\n[Resolve]\nDNS=%s\n' "$NEW_DNS" >> "$resolved_conf" fi capture_after "$resolved_conf" "$idx" changed_resolved=true else FILES_AFTER[$idx]=$(b64_encode "DRY_RUN_NO_CHANGE") fi FILE_COUNT=$((FILE_COUNT + 1)) # ── Layer 2: Netplan YAML files ─────────────────────────────────────────── # Check if any netplan file has a hardcoded nameservers block local netplan_dir="/etc/netplan" if [[ -d "$netplan_dir" ]]; then local netplan_files # Find yaml files that contain nameservers netplan_files=$(grep -rl "nameservers" "$netplan_dir" 2>/dev/null || true) for np_file in $netplan_files; do [[ -z "$np_file" ]] && continue local np_idx=$FILE_COUNT capture_before "$np_file" "$np_idx" if [[ "$DRY_RUN" == "false" ]]; then # Replace all IPs in the addresses list under nameservers block # This handles YAML indentation variants with python3 (available on Ubuntu) python3 - "$np_file" "$NEW_DNS" <<'PYEOF' import sys, re filepath = sys.argv[1] new_dns = sys.argv[2] with open(filepath, 'r') as f: content = f.read() # Replace nameservers addresses block # Matches: addresses: [x.x.x.x, y.y.y.y] or multi-line list # Single-line format: addresses: [x.x.x.x, y.y.y.y] content = re.sub( r'(nameservers:\s*\n\s+addresses:\s*\[)[^\]]+(\])', r'\g<1>' + new_dns + r'\2', content ) # Also handle: addresses: [x.x.x.x, y.y.y.y] on same line as nameservers content = re.sub( r'(addresses:\s*\[)[^\]]+(\])', r'\g<1>' + new_dns + r'\2', content ) with open(filepath, 'w') as f: f.write(content) print("OK") PYEOF capture_after "$np_file" "$np_idx" changed_netplan=true else FILES_AFTER[$np_idx]=$(b64_encode "DRY_RUN_NO_CHANGE") fi FILE_COUNT=$((FILE_COUNT + 1)) done fi # ── Restart services ────────────────────────────────────────────────────── if [[ "$DRY_RUN" == "false" ]]; then if [[ "$changed_resolved" == "true" ]]; then systemctl restart systemd-resolved 2>&1 || true # Re-link resolv.conf if it's a symlink to stub resolver if [[ -L /etc/resolv.conf ]]; then ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf 2>/dev/null || true fi SERVICE_RESTARTED="systemd-resolved" fi if [[ "$changed_netplan" == "true" ]]; then netplan apply 2>&1 || true SERVICE_RESTARTED="${SERVICE_RESTARTED:+$SERVICE_RESTARTED,}netplan" fi fi } # ──────────────────────────────────────────────────────────────────────────── # CENTOS 7 / ROCKY LINUX 7 # Target: /etc/sysconfig/network-scripts/ifcfg- # Service: NetworkManager # ──────────────────────────────────────────────────────────────────────────── change_dns_el7() { # Find NIC with default route local default_nic default_nic=$(ip route 2>/dev/null | grep '^default' | awk '{print $5}' | head -1) if [[ -z "$default_nic" ]]; then output_line "STATUS" "failed" output_line "ERROR" "Cannot determine default route NIC" exit 1 fi local ifcfg_file="/etc/sysconfig/network-scripts/ifcfg-${default_nic}" if [[ ! -f "$ifcfg_file" ]]; then output_line "STATUS" "failed" output_line "ERROR" "ifcfg file not found: $ifcfg_file" exit 1 fi output_line "NIC" "$default_nic" local idx=$FILE_COUNT capture_before "$ifcfg_file" "$idx" if [[ "$DRY_RUN" == "false" ]]; then # Remove existing DNS1, DNS2, DNS3 lines and insert new DNS1 sed -i '/^DNS[0-9]*=/d' "$ifcfg_file" printf 'DNS1=%s\n' "$NEW_DNS" >> "$ifcfg_file" capture_after "$ifcfg_file" "$idx" FILE_COUNT=$((FILE_COUNT + 1)) systemctl restart NetworkManager 2>&1 || true SERVICE_RESTARTED="NetworkManager" # Wait briefly for NM to re-apply settings sleep 2 else FILES_AFTER[$idx]=$(b64_encode "DRY_RUN_NO_CHANGE") FILE_COUNT=$((FILE_COUNT + 1)) fi } # ──────────────────────────────────────────────────────────────────────────── # ROCKY LINUX 8 # Detect NM connection file format: ifcfg (legacy) or keyfile (.nmconnection) # ──────────────────────────────────────────────────────────────────────────── change_dns_rocky8() { # Get the active connection name and its backing file local nm_conn_name nm_conn_file nm_conn_format nm_conn_name=$(nmcli -t -f NAME,DEVICE con show --active 2>/dev/null | \ grep -v '^lo' | head -1 | cut -d: -f1) if [[ -z "$nm_conn_name" ]]; then # Fallback: try to detect via default route NIC local default_nic default_nic=$(ip route 2>/dev/null | grep '^default' | awk '{print $5}' | head -1) nm_conn_name=$(nmcli -t -f NAME,DEVICE con show --active 2>/dev/null | \ grep ":${default_nic}$" | cut -d: -f1 | head -1) fi if [[ -z "$nm_conn_name" ]]; then output_line "STATUS" "failed" output_line "ERROR" "Cannot determine active NetworkManager connection" exit 1 fi output_line "NM_CONNECTION" "$nm_conn_name" # Detect file format from nmcli nm_conn_file=$(nmcli -t -f NAME,FILENAME con show 2>/dev/null | \ grep "^${nm_conn_name}:" | cut -d: -f2-) if [[ "$nm_conn_file" == *"system-connections"* ]]; then nm_conn_format="keyfile" elif [[ "$nm_conn_file" == *"ifcfg"* ]]; then nm_conn_format="ifcfg" else # Default: check if ifcfg file exists for the default NIC local default_nic default_nic=$(ip route 2>/dev/null | grep '^default' | awk '{print $5}' | head -1) local ifcfg_candidate="/etc/sysconfig/network-scripts/ifcfg-${default_nic}" if [[ -f "$ifcfg_candidate" ]]; then nm_conn_file="$ifcfg_candidate" nm_conn_format="ifcfg" else nm_conn_format="keyfile" nm_conn_file="/etc/NetworkManager/system-connections/${nm_conn_name}.nmconnection" fi fi output_line "NM_FORMAT" "$nm_conn_format" output_line "NM_FILE" "$nm_conn_file" local idx=$FILE_COUNT capture_before "$nm_conn_file" "$idx" if [[ "$DRY_RUN" == "false" ]]; then if [[ "$nm_conn_format" == "ifcfg" ]]; then # Legacy ifcfg format — same as EL7 sed -i '/^DNS[0-9]*=/d' "$nm_conn_file" printf 'DNS1=%s\n' "$NEW_DNS" >> "$nm_conn_file" capture_after "$nm_conn_file" "$idx" FILE_COUNT=$((FILE_COUNT + 1)) systemctl restart NetworkManager 2>&1 || true SERVICE_RESTARTED="NetworkManager" sleep 2 else # Keyfile format — use nmcli to modify then reload # nmcli modifies the .nmconnection file directly capture_before "$nm_conn_file" "$idx" nmcli con mod "$nm_conn_name" ipv4.dns "$NEW_DNS" 2>&1 nmcli con mod "$nm_conn_name" ipv4.ignore-auto-dns "yes" 2>&1 || true # Bring the connection up to apply changes nmcli con up "$nm_conn_name" 2>&1 || true capture_after "$nm_conn_file" "$idx" FILE_COUNT=$((FILE_COUNT + 1)) SERVICE_RESTARTED="NetworkManager(nmcli)" sleep 2 fi else FILES_AFTER[$idx]=$(b64_encode "DRY_RUN_NO_CHANGE") FILE_COUNT=$((FILE_COUNT + 1)) fi } # ── Dispatch to distro-specific function ───────────────────────────────────── case "$DISTRO_ID" in ubuntu) change_dns_ubuntu ;; centos) change_dns_el7 ;; rocky) # Rocky 8+ uses different NM handling if [[ "${DISTRO_VERSION%%.*}" -ge 8 ]]; then change_dns_rocky8 else change_dns_el7 fi ;; rhel|almalinux) # Treat like Rocky — same NM stack if [[ "${DISTRO_VERSION%%.*}" -ge 8 ]]; then change_dns_rocky8 else change_dns_el7 fi ;; *) output_line "STATUS" "failed" output_line "ERROR" "Unsupported distro: $DISTRO_ID $DISTRO_VERSION" exit 1 ;; esac # ── Output file tracking results ───────────────────────────────────────────── output_line "FILE_COUNT" "$FILE_COUNT" output_line "SERVICE_RESTARTED" "$SERVICE_RESTARTED" for ((i=0; i/dev/null; then local nslookup_out nslookup_out=$(nslookup "$domain" "$dns_server" 2>&1) || true if echo "$nslookup_out" | grep -qiE 'Address.*[0-9]+\.[0-9]+|answer:'; then VALIDATION_STATUS="success" else VALIDATION_STATUS="failed" fi VALIDATION_OUTPUT=$(b64_encode "$nslookup_out") elif command -v dig &>/dev/null; then local dig_out dig_out=$(dig "@${dns_server}" "$domain" A +short 2>&1) || true if [[ -n "$dig_out" ]] && echo "$dig_out" | grep -qE '^[0-9]+\.[0-9]+'; then VALIDATION_STATUS="success" else VALIDATION_STATUS="failed" fi VALIDATION_OUTPUT=$(b64_encode "$dig_out") elif command -v host &>/dev/null; then local host_out host_out=$(host "$domain" "$dns_server" 2>&1) || true if echo "$host_out" | grep -qi 'has address'; then VALIDATION_STATUS="success" else VALIDATION_STATUS="failed" fi VALIDATION_OUTPUT=$(b64_encode "$host_out") else VALIDATION_STATUS="skipped" VALIDATION_OUTPUT=$(b64_encode "No DNS lookup tool found (nslookup/dig/host)") fi } if [[ "$DRY_RUN" == "false" ]]; then do_validation "$NEW_DNS" "$DNS_DOMAIN" else # In dry-run: validate against current DNS to show current resolution state CURRENT_DNS=$(grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | head -1) if [[ -n "$CURRENT_DNS" ]]; then do_validation "$CURRENT_DNS" "$DNS_DOMAIN" VALIDATION_STATUS="dryrun_${VALIDATION_STATUS}" else VALIDATION_STATUS="dryrun_skipped" VALIDATION_OUTPUT=$(b64_encode "No nameserver in resolv.conf to test against") fi fi output_line "VALIDATION" "$VALIDATION_STATUS" output_line "VALIDATION_OUTPUT" "$VALIDATION_OUTPUT" # ── Final status ────────────────────────────────────────────────────────────── if [[ "$DRY_RUN" == "true" ]]; then output_line "STATUS" "dryrun" elif [[ "$FILE_COUNT" -eq 0 ]]; then output_line "STATUS" "failed" output_line "ERROR" "No files were modified — distro logic may not have run" else output_line "STATUS" "success" fi exit 0