play/scripts/netcup-dns.sh

269 lines
9.6 KiB
Bash
Executable File

#!/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 <domain> # list all records
# netcup-dns.sh add <domain> <host> <type> <value> # add a record (idempotent)
# netcup-dns.sh delete <domain> <recordId> # delete a record
# netcup-dns.sh --dry-run add <domain> ... # preview without API calls
# netcup-dns.sh --yes add <domain> ... # 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