269 lines
9.6 KiB
Bash
Executable File
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
|