#!/usr/bin/env bash ######################################################################################### #### hetzner-dns-manager.sh — Manage DNS zones and records via the Hetzner DNS API #### #### List zones, add/update/delete records, BIND export/import, audit, bulk ops #### #### Requires: bash 4+, curl, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./hetzner-dns-manager.sh --zones #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Colors (pre-initialized) ───────────────────────────────────────── RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" setup_colors() { if [[ "${COLOR:-auto}" == "never" ]]; then return fi if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*"; exit 1; } section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } field() { printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2" } field_color() { printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2" } elapsed() { local end_time end_time=$(date +%s) echo "$(( end_time - START_TIME ))s" } # ── Defaults ────────────────────────────────────────────────────────── RUN_MODE="" ZONE_NAME="" ZONE_ID="" RECORD_ID="" RECORD_TYPE="" RECORD_NAME="" RECORD_VALUE="" RECORD_TTL="3600" CSV_FILE="" OUTPUT_FORMAT="${HDM_FORMAT:-table}" FORCE="false" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" IMPORT_FILE="" EXPORT_FILE="" # ── Credentials ─────────────────────────────────────────────────────── HETZNER_DNS_TOKEN="${HETZNER_DNS_TOKEN:-}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" ACTION_OK=0 ACTION_FAIL=0 # ── API helpers ────────────────────────────────────────────────────── hdns_api() { local method="$1" endpoint="$2" shift 2 local attempt=0 max_attempts=3 while (( attempt < max_attempts )); do local http_code http_code=$(curl -s -o /tmp/hdm_resp.json -w "%{http_code}" \ -X "$method" \ -H "Auth-API-Token: ${HETZNER_DNS_TOKEN}" \ -H "Content-Type: application/json" \ "https://dns.hetzner.com/api/v1${endpoint}" "$@") verbose "API ${method} ${endpoint} → HTTP ${http_code}" if [[ "$http_code" == "429" ]]; then ((attempt++)) || true local wait=$(( attempt * 5 )) warn "Rate limited — retrying in ${wait}s (attempt ${attempt}/${max_attempts})" sleep "$wait" continue fi if [[ "$http_code" =~ ^[45] ]]; then local errmsg errmsg=$(jq -r '.error.message // .message // empty' /tmp/hdm_resp.json 2>/dev/null) [[ -n "$errmsg" ]] && verbose "API error: ${errmsg}" fi cat /tmp/hdm_resp.json return 0 done err "API request failed after ${max_attempts} attempts: ${method} ${endpoint}" return 1 } hdns_api_raw() { local method="$1" endpoint="$2" shift 2 curl -s \ -X "$method" \ -H "Auth-API-Token: ${HETZNER_DNS_TOKEN}" \ "https://dns.hetzner.com/api/v1${endpoint}" "$@" } check_credentials() { [[ -z "$HETZNER_DNS_TOKEN" ]] && die "HETZNER_DNS_TOKEN not set" } check_deps() { command -v curl &>/dev/null || die "curl is required" command -v jq &>/dev/null || die "jq is required" } # ── Zone helpers ───────────────────────────────────────────────────── resolve_zone_id() { local name="$1" local resp resp=$(hdns_api GET "/zones?name=${name}") local zid zid=$(echo "$resp" | jq -r '.zones[0].id // empty' 2>/dev/null) if [[ -z "$zid" ]]; then die "Zone not found: ${name}" fi echo "$zid" } # ══════════════════════════════════════════════════════════════════════ # ZONES # ══════════════════════════════════════════════════════════════════════ do_zones() { local page=1 per_page=100 all_data="[]" while true; do local resp resp=$(hdns_api GET "/zones?page=${page}&per_page=${per_page}") local page_data page_data=$(echo "$resp" | jq '.zones // []' 2>/dev/null) local page_count page_count=$(echo "$page_data" | jq 'length' 2>/dev/null || echo 0) [[ "$page_count" -eq 0 ]] && break all_data=$(echo -e "${all_data}\n${page_data}" | jq -s 'add' 2>/dev/null) (( page_count < per_page )) && break ((page++)) || true done local total total=$(echo "$all_data" | jq 'length' 2>/dev/null || echo 0) [[ "$total" -eq 0 ]] && die "No zones found" case "$OUTPUT_FORMAT" in json) echo "$all_data" | jq '.' ;; prometheus) cat </dev/null \ | while IFS=$'\t' read -r zid name status rcount ttl; do printf " %-34s %-18s %-10s %-8s %-8s\n" \ "${zid:0:32}" "${name:0:16}" "$status" "$rcount" "$ttl" done echo "" field "Zones:" "$total" ;; esac } # ══════════════════════════════════════════════════════════════════════ # RECORDS # ══════════════════════════════════════════════════════════════════════ do_records() { [[ -z "$ZONE_NAME" ]] && die "Specify --zone DOMAIN" local zid zid=$(resolve_zone_id "$ZONE_NAME") local resp resp=$(hdns_api GET "/records?zone_id=${zid}") local records records=$(echo "$resp" | jq '.records // []' 2>/dev/null) local total total=$(echo "$records" | jq 'length' 2>/dev/null || echo 0) case "$OUTPUT_FORMAT" in json) echo "$records" | jq '.' ;; prometheus) cat </dev/null \ | while IFS=$'\t' read -r rid rtype rname rvalue rttl; do printf " %-34s %-6s %-18s %-26s %-6s\n" \ "${rid:0:32}" "$rtype" "${rname:0:16}" "${rvalue:0:24}" "$rttl" done echo "" field "Records:" "$total" ;; esac } # ══════════════════════════════════════════════════════════════════════ # ADD # ══════════════════════════════════════════════════════════════════════ do_add() { [[ -z "$ZONE_NAME" ]] && die "Specify --zone DOMAIN" [[ -z "$RECORD_TYPE" ]] && die "Specify --type TYPE" [[ -z "$RECORD_NAME" ]] && die "Specify --name NAME" [[ -z "$RECORD_VALUE" ]] && die "Specify --value VALUE" local zid zid=$(resolve_zone_id "$ZONE_NAME") local payload payload=$(jq -n \ --arg zid "$zid" \ --arg type "$RECORD_TYPE" \ --arg name "$RECORD_NAME" \ --arg value "$RECORD_VALUE" \ --argjson ttl "$RECORD_TTL" \ '{zone_id: $zid, type: $type, name: $name, value: $value, ttl: $ttl}') local resp resp=$(hdns_api POST "/records" -d "$payload") local rid rid=$(echo "$resp" | jq -r '.record.id // empty' 2>/dev/null) if [[ -n "$rid" ]]; then echo -e " ${GREEN}✓${RESET} Record created: ${RECORD_TYPE} ${RECORD_NAME} → ${RECORD_VALUE} (ID: ${rid})" ((ACTION_OK++)) || true else local errmsg errmsg=$(echo "$resp" | jq -r '.error.message // .message // "unknown error"' 2>/dev/null) echo -e " ${RED}✗${RESET} Failed to create record: ${errmsg}" ((ACTION_FAIL++)) || true fi } # ══════════════════════════════════════════════════════════════════════ # UPDATE # ══════════════════════════════════════════════════════════════════════ do_update() { [[ -z "$RECORD_ID" ]] && die "Specify --record-id ID" [[ -z "$ZONE_NAME" ]] && die "Specify --zone DOMAIN" [[ -z "$RECORD_TYPE" ]] && die "Specify --type TYPE" [[ -z "$RECORD_NAME" ]] && die "Specify --name NAME" [[ -z "$RECORD_VALUE" ]] && die "Specify --value VALUE" local zid zid=$(resolve_zone_id "$ZONE_NAME") local payload payload=$(jq -n \ --arg zid "$zid" \ --arg type "$RECORD_TYPE" \ --arg name "$RECORD_NAME" \ --arg value "$RECORD_VALUE" \ --argjson ttl "$RECORD_TTL" \ '{zone_id: $zid, type: $type, name: $name, value: $value, ttl: $ttl}') local resp resp=$(hdns_api PUT "/records/${RECORD_ID}" -d "$payload") local rid rid=$(echo "$resp" | jq -r '.record.id // empty' 2>/dev/null) if [[ -n "$rid" ]]; then echo -e " ${GREEN}✓${RESET} Record updated: ${RECORD_TYPE} ${RECORD_NAME} → ${RECORD_VALUE} (ID: ${rid})" ((ACTION_OK++)) || true else local errmsg errmsg=$(echo "$resp" | jq -r '.error.message // .message // "unknown error"' 2>/dev/null) echo -e " ${RED}✗${RESET} Failed to update record: ${errmsg}" ((ACTION_FAIL++)) || true fi } # ══════════════════════════════════════════════════════════════════════ # DELETE # ══════════════════════════════════════════════════════════════════════ do_delete() { [[ -z "$RECORD_ID" ]] && die "Specify --record-id ID" [[ "$FORCE" != "true" ]] && die "Delete is destructive — use --force to confirm" local resp resp=$(hdns_api DELETE "/records/${RECORD_ID}") echo -e " ${GREEN}✓${RESET} Record deleted: ${RECORD_ID}" ((ACTION_OK++)) || true } # ══════════════════════════════════════════════════════════════════════ # EXPORT # ══════════════════════════════════════════════════════════════════════ do_export() { [[ -z "$ZONE_NAME" ]] && die "Specify --zone DOMAIN" local zid zid=$(resolve_zone_id "$ZONE_NAME") local zone_data zone_data=$(hdns_api_raw GET "/zones/${zid}/export") if [[ -n "$EXPORT_FILE" ]]; then echo "$zone_data" > "$EXPORT_FILE" echo -e " ${GREEN}✓${RESET} Zone exported to ${EXPORT_FILE}" else echo "$zone_data" fi } # ══════════════════════════════════════════════════════════════════════ # IMPORT # ══════════════════════════════════════════════════════════════════════ do_import() { [[ -z "$ZONE_NAME" ]] && die "Specify --zone DOMAIN" [[ -z "$IMPORT_FILE" ]] && die "Specify --file FILE" [[ ! -f "$IMPORT_FILE" ]] && die "File not found: ${IMPORT_FILE}" [[ "$FORCE" != "true" ]] && die "Import replaces ALL zone records — use --force to confirm" local zid zid=$(resolve_zone_id "$ZONE_NAME") local zone_data zone_data=$(cat "$IMPORT_FILE") local http_code http_code=$(curl -s -o /tmp/hdm_resp.json -w "%{http_code}" \ -X POST \ -H "Auth-API-Token: ${HETZNER_DNS_TOKEN}" \ -H "Content-Type: text/plain" \ "https://dns.hetzner.com/api/v1/zones/${zid}/import" \ --data-binary "$zone_data") if [[ "$http_code" =~ ^2 ]]; then echo -e " ${GREEN}✓${RESET} Zone imported from ${IMPORT_FILE}" ((ACTION_OK++)) || true else local errmsg errmsg=$(jq -r '.error.message // .message // "unknown error"' /tmp/hdm_resp.json 2>/dev/null) echo -e " ${RED}✗${RESET} Import failed: ${errmsg}" ((ACTION_FAIL++)) || true fi } # ══════════════════════════════════════════════════════════════════════ # BULK ADD # ══════════════════════════════════════════════════════════════════════ do_bulk_add() { [[ -z "$ZONE_NAME" ]] && die "Specify --zone DOMAIN" [[ -z "$CSV_FILE" ]] && die "Specify --csv FILE" [[ ! -f "$CSV_FILE" ]] && die "CSV file not found: ${CSV_FILE}" local zid zid=$(resolve_zone_id "$ZONE_NAME") section_header "Bulk Add — ${ZONE_NAME}" local line_num=0 while IFS=',' read -r rtype rname rvalue rttl; do ((line_num++)) || true [[ -z "$rtype" || "$rtype" =~ ^# ]] && continue rtype=$(echo "$rtype" | xargs) rname=$(echo "$rname" | xargs) rvalue=$(echo "$rvalue" | xargs) rttl=$(echo "${rttl:-3600}" | xargs) local payload payload=$(jq -n \ --arg zid "$zid" \ --arg type "$rtype" \ --arg name "$rname" \ --arg value "$rvalue" \ --argjson ttl "$rttl" \ '{zone_id: $zid, type: $type, name: $name, value: $value, ttl: $ttl}') local resp resp=$(hdns_api POST "/records" -d "$payload") local rid rid=$(echo "$resp" | jq -r '.record.id // empty' 2>/dev/null) if [[ -n "$rid" ]]; then echo -e " ${GREEN}✓${RESET} ${rtype} ${rname} → ${rvalue} (line ${line_num})" ((ACTION_OK++)) || true else echo -e " ${RED}✗${RESET} ${rtype} ${rname} → ${rvalue} (line ${line_num})" ((ACTION_FAIL++)) || true fi sleep 0.5 done < "$CSV_FILE" echo "" field_color "Succeeded:" "${GREEN}${ACTION_OK}${RESET}" if [[ "$ACTION_FAIL" -gt 0 ]]; then field_color "Failed:" "${RED}${ACTION_FAIL}${RESET}" fi } # ══════════════════════════════════════════════════════════════════════ # AUDIT # ══════════════════════════════════════════════════════════════════════ do_audit() { [[ -z "$ZONE_NAME" ]] && die "Specify --zone DOMAIN" local zid zid=$(resolve_zone_id "$ZONE_NAME") local resp resp=$(hdns_api GET "/records?zone_id=${zid}") local records records=$(echo "$resp" | jq '.records // []' 2>/dev/null) local total total=$(echo "$records" | jq 'length' 2>/dev/null || echo 0) local warnings=0 if [[ "$OUTPUT_FORMAT" != "prometheus" ]]; then section_header "DNS Audit — ${ZONE_NAME}" field "Records:" "$total" echo "" fi # Check SOA local soa_count soa_count=$(echo "$records" | jq '[.[] | select(.type == "SOA")] | length' 2>/dev/null || echo 0) if [[ "$soa_count" -eq 0 ]]; then ((warnings++)) || true [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${YELLOW}⚠${RESET} No SOA record found" else [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${GREEN}✓${RESET} SOA record present" fi # Check NS local ns_count ns_count=$(echo "$records" | jq '[.[] | select(.type == "NS")] | length' 2>/dev/null || echo 0) if [[ "$ns_count" -eq 0 ]]; then ((warnings++)) || true [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${RED}✗${RESET} No NS records found" elif [[ "$ns_count" -lt 2 ]]; then ((warnings++)) || true [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${YELLOW}⚠${RESET} Only ${ns_count} NS record(s) — recommend at least 2" else [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${GREEN}✓${RESET} ${ns_count} NS records" fi # Check common types for rtype in A AAAA MX TXT; do local rcount rcount=$(echo "$records" | jq --arg t "$rtype" '[.[] | select(.type == $t)] | length' 2>/dev/null || echo 0) if [[ "$rcount" -eq 0 ]]; then ((warnings++)) || true [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${YELLOW}⚠${RESET} No ${rtype} records found" else [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${GREEN}✓${RESET} ${rcount} ${rtype} record(s)" fi done # Check low TTLs local low_ttl low_ttl=$(echo "$records" | jq '[.[] | select(.ttl < 300 and .ttl > 0)] | length' 2>/dev/null || echo 0) if [[ "$low_ttl" -gt 0 ]]; then ((warnings++)) || true [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${YELLOW}⚠${RESET} ${low_ttl} record(s) with TTL < 300s" else [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${GREEN}✓${RESET} All TTLs ≥ 300s" fi # Check wildcards local wildcard wildcard=$(echo "$records" | jq '[.[] | select(.name | startswith("*"))] | length' 2>/dev/null || echo 0) if [[ "$wildcard" -gt 0 ]]; then [[ "$OUTPUT_FORMAT" != "prometheus" ]] && echo -e " ${CYAN}ℹ${RESET} ${wildcard} wildcard record(s)" fi if [[ "$OUTPUT_FORMAT" == "prometheus" ]]; then cat <