#!/bin/bash # /var/www/rp/scripts/netcup-dns.sh # Reusable Netcup DNS tool for thehowlingwhispers.com (and any Netcup-managed zone). # # Credentials: /root/.netcup-creds (chmod 600, NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD) # Audit log: /var/log/netcup-dns.log # # Usage: # netcup-dns.sh list # list all records # netcup-dns.sh add # add a record (idempotent) # netcup-dns.sh delete # delete a record # netcup-dns.sh --dry-run add ... # preview without API calls # netcup-dns.sh --yes add ... # skip confirmation prompt # netcup-dns.sh --json list ... # raw JSON output # # Examples: # netcup-dns.sh list thehowlingwhispers.com # netcup-dns.sh add thehowlingwhispers.com rp A 159.195.194.180 # netcup-dns.sh add thehowlingwhispers.com sandbox CNAME rp.thehowlingwhispers.com # netcup-dns.sh add thehowlingwhispers.com @ TXT "v=spf1 -all" # netcup-dns.sh --dry-run add thehowlingwhispers.com blog A 159.195.194.180 # netcup-dns.sh --json list thehowlingwhispers.com set -euo pipefail # ─── config ──────────────────────────────────────────────────── CREDS_FILE="${NETCUP_CREDS_FILE:-/root/.netcup-creds}" LOG_FILE="${NETCUP_DNS_LOG:-/var/log/netcup-dns.log}" API_BASE="https://ccp.netcup.net/api/v1" JQ_FORMAT='@json' # not used; kept for clarity # ─── colors ─────────────────────────────────────────────────── if [ -t 1 ]; then C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'; C_DIM=$'\e[2m'; C_RST=$'\e[0m' else C_RED=''; C_GRN=''; C_YEL=''; C_DIM=''; C_RST='' fi ok() { printf '%s✓%s %s\n' "$C_GRN" "$C_RST" "$*"; } warn() { printf '%s!%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; } err() { printf '%s✗%s %s\n' "$C_RED" "$C_RST" "$*" >&2; } # ─── flag parsing ───────────────────────────────────────────── DRY_RUN=false ASSUME_YES=false JSON_OUT=false SUBCMD="" ARGS=() while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=true; shift ;; --yes|-y) ASSUME_YES=true; shift ;; --json) JSON_OUT=true; shift ;; --help|-h) sed -n '2,28p' "$0" exit 0 ;; list|add|delete) SUBCMD="$1"; shift; ARGS=("$@"); break ;; *) err "Unknown flag or command: $1"; exit 2 ;; esac done # ─── log helper ─────────────────────────────────────────────── log() { printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" >> "$LOG_FILE" } # ─── load creds (chmod 600 enforced) ──────────────────────── if [ ! -f "$CREDS_FILE" ]; then err "Credentials file not found: $CREDS_FILE" err "Create it with NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD (chmod 600)" exit 1 fi PERMS=$(stat -c '%a' "$CREDS_FILE" 2>/dev/null || echo "") if [ "$PERMS" != "600" ] && [ "$PERMS" != "400" ]; then err "Credentials file must be chmod 600 or 400 (currently: $PERMS). Refusing to run." exit 1 fi # shellcheck disable=SC1090 source "$CREDS_FILE" if [ -z "${NETCUP_CUSTOMER:-}" ] || [ -z "${NETCUP_API_KEY:-}" ] || [ -z "${NETCUP_API_PASSWORD:-}" ]; then err "Missing one of NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD in $CREDS_FILE" exit 1 fi # ─── API helpers ───────────────────────────────────────────── # Netcup requires X-Api-Key + X-Customer-Number headers, plus # X-Session-Id for authenticated calls. Login returns sessionId. SESSION_ID="" SESSION_LOGGED_IN_AT=0 SESSION_TTL=240 # refresh session every 4 minutes login() { local now now=$(date +%s) if [ -n "$SESSION_ID" ] && [ $((now - SESSION_LOGGED_IN_AT)) -lt $SESSION_TTL ]; then return 0 fi local response http_code body response=$(curl -sS -w '\n%{http_code}' -X POST \ -H "X-Api-Key: ${NETCUP_API_KEY}" \ -H "X-Customer-Number: ${NETCUP_CUSTOMER}" \ -H "Content-Type: application/json" \ "${API_BASE}/login") http_code=$(printf '%s' "$response" | tail -n1) body=$(printf '%s' "$response" | sed '$d') if [ "$http_code" != "200" ]; then err "Login failed (HTTP $http_code): $body" log "ERROR" "login failed: HTTP $http_code" exit 1 fi SESSION_ID=$(printf '%s' "$body" | jq -r '.sessionId') SESSION_LOGGED_IN_AT=$now log "INFO" "login OK (session refreshed)" } api() { local method=$1; shift local path=$1; shift local body=${1:-} login local args=(-sS -w '\n%{http_code}' -X "$method" -H "X-Session-Id: $SESSION_ID" -H "Content-Type: application/json") if [ -n "$body" ]; then args+=(-d "$body") fi curl "${args[@]}" "${API_BASE}${path}" } # ─── subdomain ────────────────────────────────────────────── require_domain() { if [ -z "${1:-}" ]; then err "Domain required"; exit 1; fi echo "$1" } require_arg() { if [ -z "${1:-}" ]; then err "$2 required"; exit 1; fi echo "$1" } # ─── subcommands ───────────────────────────────────────────── cmd_list() { local domain domain=$(require_domain "${ARGS[0]:-}") local response http_code body response=$(api GET "/dns/${domain}") http_code=$(printf '%s' "$response" | tail -n1) body=$(printf '%s' "$response" | sed '$d') if [ "$http_code" != "200" ]; then err "List failed (HTTP $http_code): $body" log "ERROR" "list $domain failed: HTTP $http_code" exit 1 fi if $JSON_OUT; then printf '%s\n' "$body" else printf '%s%-8s %-30s %-25s %s%s\n' "$C_DIM" "TYPE" "HOST" "VALUE" "TTL" "$C_RST" printf '%s\n' "$body" | jq -r '.[] | [.type, .hostname, (.value|tostring|.[0:25]), .ttl] | @tsv' | \ while IFS=$'\t' read -r t h v ttl; do printf '%-8s %-30s %-25s %s\n' "$t" "$h" "$v" "$ttl" done fi log "INFO" "list $domain (HTTP $http_code, $(printf '%s' "$body" | jq 'length') records)" } cmd_add() { local domain host type value domain=$(require_domain "${ARGS[0]:-}") host=$(require_arg "${ARGS[1]:-}" "host") type=$(require_arg "${ARGS[2]:-}" "type") value=$(require_arg "${ARGS[3]:-}" "value") # Idempotency check: list first local list_body list_body=$(api GET "/dns/${domain}" | sed '$d') local existing existing=$(printf '%s' "$list_body" | jq -r --arg h "$host" --arg t "$type" --arg v "$value" \ '.[] | select(.hostname == $h and .type == $t and (.value|tostring) == $v) | .id') if [ -n "$existing" ] && [ "$existing" != "null" ]; then ok "Already exists: $domain $host $type $value (recordId=$existing) — no-op" log "INFO" "add $domain $host $type $value — no-op (already exists, id=$existing)" return 0 fi warn "About to add: $domain $host $type $value" if ! $ASSUME_YES && [ -t 0 ]; then read -r -p "Continue? [y/N] " reply case "$reply" in y|Y|yes|YES) ;; *) err "Aborted."; exit 1 ;; esac fi if $DRY_RUN; then ok "[dry-run] would POST $host $type $value to $domain" log "INFO" "add $domain $host $type $value — DRY RUN" return 0 fi # Netcup payload format local payload payload=$(jq -n --arg h "$host" --arg t "$type" --arg v "$value" \ '{hostname:$h, type:$t, value:$v, ttl:3600}') local response http_code body response=$(api POST "/dns/${domain}" "$payload") http_code=$(printf '%s' "$response" | tail -n1) body=$(printf '%s' "$response" | sed '$d') if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then err "Add failed (HTTP $http_code): $body" log "ERROR" "add $domain $host $type $value failed: HTTP $http_code" exit 1 fi local new_id new_id=$(printf '%s' "$body" | jq -r '.id // .recordId // empty') ok "Added: $domain $host $type $value (recordId=$new_id)" log "INFO" "add $domain $host $type $value OK (id=$new_id)" } cmd_delete() { local domain record_id domain=$(require_domain "${ARGS[0]:-}") record_id=$(require_arg "${ARGS[1]:-}" "recordId") warn "About to delete: $domain recordId=$record_id" if ! $ASSUME_YES && [ -t 0 ]; then read -r -p "Continue? [y/N] " reply case "$reply" in y|Y|yes|YES) ;; *) err "Aborted."; exit 1 ;; esac fi if $DRY_RUN; then ok "[dry-run] would DELETE recordId=$record_id from $domain" log "INFO" "delete $domain $record_id — DRY RUN" return 0 fi local response http_code body response=$(api DELETE "/dns/${domain}/${record_id}") http_code=$(printf '%s' "$response" | tail -n1) body=$(printf '%s' "$response" | sed '$d') if [ "$http_code" != "200" ] && [ "$http_code" != "204" ]; then err "Delete failed (HTTP $http_code): $body" log "ERROR" "delete $domain $record_id failed: HTTP $http_code" exit 1 fi ok "Deleted: $domain recordId=$record_id" log "INFO" "delete $domain $record_id OK" } # ─── dispatch ──────────────────────────────────────────────── case "$SUBCMD" in list) cmd_list ;; add) cmd_add ;; delete) cmd_delete ;; "") err "Subcommand required (list|add|delete). Try --help." sed -n '2,28p' "$0" >&2 exit 2 ;; *) err "Unknown subcommand: $SUBCMD" exit 2 ;; esac