#!/usr/bin/env bash set -Eeuo pipefail IFS=$'\n\t' # ============================================================================== # OTV Gateway Nginx Cipher Hardening Script (Auto + Status + Consent) # v2.2 - 2026-02-16: # - Hardcoded targets (no user inputs): # * Namespace: ntv-system # * ConfigMap: apigateway-conf # * Key: virtualhost.conf # * Mounted path in pod: /app/nginx/virtualhost/virtualhost.conf # * Pod discovery token: ntv-gateway-svc # - Preflight constructs "inputs" by querying cluster: # * selects a running gateway pod # * selects a likely nginx container in that pod # * extracts current ssl_ciphers line from ConfigMap data + from mounted file # * decides if change is needed # - Consent: # * If NOT compliant => always ask Y/y before applying # * If compliant => conclude and exit (no consent) # * Rollback => always ask Y/y before restoring # - Rollback restore: # * uses pointer file if present # * fallback to newest backup in STATE_DIR # # How to run (examples): # 1) Actual hardening run (Steps 1-3): # sudo ./gateway_cipher_hardening.sh # # 2) Dry-run (no changes; prints what would be run): # sudo ./gateway_cipher_hardening.sh --dry-run # # 3) Rollback (restore ConfigMap backup + nginx reload): # sudo ./gateway_cipher_hardening.sh --rollback # # 4) Dry-run rollback (shows what rollback would do; no changes): # sudo ./gateway_cipher_hardening.sh --rollback --dry-run # # Notes: # - The script auto-detects the gateway pod by searching for "ntv-gateway-svc". # - You will be prompted for consent (type Y/y) before applying changes or rollback. # - If already compliant, it exits without prompting. # ============================================================================== SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" TS="$(date +%Y%m%d_%H%M%S)" LOG_FILE="${SCRIPT_DIR}/gateway_cipher_hardening_${TS}.log" STATE_DIR="${SCRIPT_DIR}/.gateway_cipher_state" mkdir -p "${STATE_DIR}" log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "${LOG_FILE}" >&2; } die() { log "ERROR: $*" log "Log: ${LOG_FILE}" log "State dir: ${STATE_DIR}" exit 1 } on_err() { local exit_code=$? local line_no=$1 log "ERROR: Failed at line ${line_no} (exit code ${exit_code})." log "Last command: ${BASH_COMMAND}" log "Log: ${LOG_FILE}" exit "${exit_code}" } trap 'on_err ${LINENO}' ERR # Read-only runner: always executes (even in --dry-run) to collect real status run_ro() { local cmd="$*" log "[READ] ${cmd}" bash -o pipefail -c "${cmd}" 2>&1 | tee -a "${LOG_FILE}" >&2 } # Mutating runner: skipped in --dry-run run_mut() { local cmd="$*" if [[ "${DRY_RUN}" == "1" ]]; then log "[DRY-RUN][MUTATE] ${cmd}" return 0 fi log "[RUN] ${cmd}" bash -o pipefail -c "${cmd}" 2>&1 | tee -a "${LOG_FILE}" >&2 } # Backward-compatible wrapper name used by existing code: # - in dry-run, we want to still run READ operations that gather status. # - therefore, keep run() as READ by default. run() { run_ro "$@" } # --- Fixed settings (no user input) ------------------------------------------- NAMESPACE="ntv-system" CONFIGMAP_NAME="apigateway-conf" CONFIG_KEY="virtualhost.conf" MOUNTED_VHOST_PATH="/app/nginx/virtualhost/virtualhost.conf" POD_GREP_TOKEN="ntv-gateway-svc" # Exact desired ssl_ciphers line DESIRED_SSL_CIPHERS_LINE="ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';" # --- Flags -------------------------------------------------------------------- DRY_RUN="0" ROLLBACK="0" # --- Backups/state ------------------------------------------------------------- CM_BACKUP_JSON="${STATE_DIR}/${CONFIGMAP_NAME}_${NAMESPACE}.bak_${TS}.json" CM_LATEST_PTR="${STATE_DIR}/${CONFIGMAP_NAME}_${NAMESPACE}.latest" record_latest_backup() { printf '%s\n' "$1" > "${CM_LATEST_PTR}"; } latest_backup_path() { [[ -f "${CM_LATEST_PTR}" ]] && cat "${CM_LATEST_PTR}" || true; } find_latest_adjacent_cm_backup() { ls -1 "${STATE_DIR}/${CONFIGMAP_NAME}_${NAMESPACE}.bak_"*.json 2>/dev/null | sort | tail -n 1 || true } # --- Arg parsing (simple) ------------------------------------------------------ usage() { cat <<'USAGE' Usage: ./gateway_cipher_hardening.sh ./gateway_cipher_hardening.sh --dry-run ./gateway_cipher_hardening.sh --rollback ./gateway_cipher_hardening.sh --rollback --dry-run USAGE } while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN="1"; shift ;; --rollback) ROLLBACK="1"; shift ;; -h|--help) usage; exit 0 ;; *) usage; die "Unknown argument: $1" ;; esac done # --- Preflight ----------------------------------------------------------------- check_commands() { local cmds=(date mkdir mktemp awk sed grep cat bash tee sort tail head tr) for c in "${cmds[@]}"; do command -v "${c}" >/dev/null 2>&1 || die "Missing command: ${c}"; done command -v kubectl >/dev/null 2>&1 || die "Missing command: kubectl" command -v python3 >/dev/null 2>&1 || die "Missing command: python3 (required to safely read dotted ConfigMap keys like 'virtualhost.conf')" } precheck_kube_access() { run_ro "kubectl version --client -o json >/dev/null" run_ro "kubectl get ns '${NAMESPACE}' >/dev/null" } precheck_configmap_exists() { run_ro "kubectl get configmap -n '${NAMESPACE}' '${CONFIGMAP_NAME}' -o jsonpath='{.metadata.name}' >/dev/null" } select_gateway_pod() { local line pod line="$(kubectl get pods -n "${NAMESPACE}" -o wide 2>/dev/null | grep -E "${POD_GREP_TOKEN}" | grep -E "Running" | head -n 1 || true)" if [[ -n "${line}" ]]; then pod="$(awk '{print $1}' <<< "${line}")" [[ -n "${pod}" ]] || die "Failed to parse pod name from line: ${line}" printf '%s\n' "${pod}" return 0 fi line="$(kubectl get pods -n "${NAMESPACE}" -o wide 2>/dev/null | grep -E "${POD_GREP_TOKEN}" | head -n 1 || true)" if [[ -n "${line}" ]]; then pod="$(awk '{print $1}' <<< "${line}")" [[ -n "${pod}" ]] || die "Failed to parse pod name from line: ${line}" printf '%s\n' "${pod}" return 0 fi line="$(kubectl get pods -A -o wide 2>/dev/null | grep -E "${POD_GREP_TOKEN}" | grep -E "Running" | head -n 1 || true)" if [[ -n "${line}" ]]; then pod="$(awk '{print $2}' <<< "${line}")" [[ -n "${pod}" ]] || die "Failed to parse pod name from line: ${line}" printf '%s\n' "${pod}" return 0 fi line="$(kubectl get pods -A -o wide 2>/dev/null | grep -E "${POD_GREP_TOKEN}" | head -n 1 || true)" if [[ -n "${line}" ]]; then pod="$(awk '{print $2}' <<< "${line}")" [[ -n "${pod}" ]] || die "Failed to parse pod name from line: ${line}" printf '%s\n' "${pod}" return 0 fi die "Could not find any pod matching token '${POD_GREP_TOKEN}' (ns ${NAMESPACE} or all namespaces)." } select_container_in_pod() { local pod="$1" local containers containers="$(kubectl get pod -n "${NAMESPACE}" "${pod}" -o jsonpath='{.spec.containers[*].name}' 2>/dev/null || true)" [[ -n "${containers}" ]] || die "Could not list containers for pod: ${pod}" local c for c in ${containers}; do if [[ "${c}" == *nginx* ]]; then printf '%s\n' "${c}" return 0 fi done printf '%s\n' "$(awk '{print $1}' <<< "${containers}")" } # --- Consent gate -------------------------------------------------------------- ask_for_consent() { local purpose="$1" if [[ "${DRY_RUN}" == "1" ]]; then log "[DRY-RUN] Skipping interactive confirmation." return 0 fi log "============================================================" log "CONSENT REQUIRED: ${purpose}" log "Type 'Y' to proceed, anything else to abort:" printf '%s\n' "Y to proceed: " | tee -a "${LOG_FILE}" >&2 read -r a if [[ "${a}" != "Y" && "${a}" != "y" ]]; then die "User aborted." fi log "Consent accepted." log "============================================================" } # --- ConfigMap helpers --------------------------------------------------------- backup_configmap_json() { log "Backing up ConfigMap ${CONFIGMAP_NAME} (ns: ${NAMESPACE}) to ${CM_BACKUP_JSON}" run_mut "kubectl get configmap -n '${NAMESPACE}' '${CONFIGMAP_NAME}' -o json > '${CM_BACKUP_JSON}'" if [[ "${DRY_RUN}" == "0" ]]; then record_latest_backup "${CM_BACKUP_JSON}" fi } # NEW: extract the ConfigMap key from a backup JSON file (avoids resourceVersion conflicts) get_cm_key_from_backup_json_to_file() { local backup_json="$1" local out="$2" run_ro "python3 - '${backup_json}' > '${out}' <<'PY' import json,sys p=sys.argv[1] obj=json.load(open(p,'r',encoding='utf-8')) data=obj.get('data',{}) sys.stdout.write(data.get('virtualhost.conf','')) PY" } restore_configmap_from_latest_backup() { local backup backup="$(latest_backup_path)" if [[ -z "${backup}" || ! -f "${backup}" ]]; then backup="$(find_latest_adjacent_cm_backup)" fi [[ -n "${backup}" && -f "${backup}" ]] || die "No ConfigMap backups found in ${STATE_DIR} for ${CONFIGMAP_NAME}_${NAMESPACE}." log "Restoring ConfigMap data key '${CONFIG_KEY}' from backup file: ${backup}" local tmp_restore tmp_restore="$(mktemp)" get_cm_key_from_backup_json_to_file "${backup}" "${tmp_restore}" # Patch ONLY the data key to avoid apply conflicts patch_cm_key_from_file "${tmp_restore}" rm -f "${tmp_restore}" || true } # Read ConfigMap key content safely (works even when key contains dots) using JSON + python3 get_cm_key_to_file() { local out="$1" run_ro "kubectl get configmap -n '${NAMESPACE}' '${CONFIGMAP_NAME}' -o json | python3 -c \"import json,sys; obj=json.load(sys.stdin); data=obj.get('data',{}); sys.stdout.write(data.get('${CONFIG_KEY}',''))\" > '${out}'" } patch_cm_key_from_file() { local in="$1" if [[ "${DRY_RUN}" == "1" ]]; then log "[DRY-RUN] Would patch ConfigMap '${CONFIGMAP_NAME}' key '${CONFIG_KEY}' from file: ${in}" return 0 fi local patch_file patch_file="$(mktemp)" python3 - "${in}" > "${patch_file}" <<'PY' import json,sys p=sys.argv[1] s=open(p,'r',encoding='utf-8').read() print(json.dumps({"data": {"virtualhost.conf": s}})) PY run_mut "kubectl patch configmap -n '${NAMESPACE}' '${CONFIGMAP_NAME}' --type merge --patch-file '${patch_file}'" rm -f "${patch_file}" || true } extract_ssl_ciphers_line_from_file() { local file="$1" grep -E "^[[:space:]]*ssl_ciphers[[:space:]]+" "${file}" | head -n 1 || true } # Option A: cipher-list comparison, ignore case and position (order) # - Parses the ssl_ciphers directive, extracts cipher tokens, lowercases them, # sorts them, and compares desired vs current as sets (order-insensitive). extract_cipher_set_from_ssl_ciphers_line() { local line="$1" python3 - <<'PY' "${line}" import re,sys line=sys.argv[1] m=re.search(r'^\s*ssl_ciphers\s+([\'"])(.*?)\1\s*;\s*$', line.strip()) if not m: # Not parseable as an ssl_ciphers directive sys.exit(0) val=m.group(2) tokens=[t.strip().lower() for t in val.split(':') if t.strip()] # Output one per line (caller sorts/uniqs) for t in tokens: print(t) PY } cipher_sets_equal_ignore_case_and_position() { local current_line="$1" local desired_line="$2" local cur tmp_cur tmp_des tmp_cur="$(mktemp)" tmp_des="$(mktemp)" # Extract tokens -> sort -> uniq extract_cipher_set_from_ssl_ciphers_line "${current_line}" | sort | uniq > "${tmp_cur}" || true extract_cipher_set_from_ssl_ciphers_line "${desired_line}" | sort | uniq > "${tmp_des}" || true # If either side produced empty set, treat as not compliant if [[ ! -s "${tmp_cur}" || ! -s "${tmp_des}" ]]; then rm -f "${tmp_cur}" "${tmp_des}" || true return 1 fi if cmp -s "${tmp_cur}" "${tmp_des}"; then rm -f "${tmp_cur}" "${tmp_des}" || true return 0 fi rm -f "${tmp_cur}" "${tmp_des}" || true return 1 } config_already_compliant_file() { local file="$1" local cur_line cur_line="$(extract_ssl_ciphers_line_from_file "${file}")" [[ -n "${cur_line}" ]] || return 1 cipher_sets_equal_ignore_case_and_position "${cur_line}" "${DESIRED_SSL_CIPHERS_LINE}" } enforce_ssl_ciphers_line() { local file_in="$1" local file_out="$2" if awk -v desired="${DESIRED_SSL_CIPHERS_LINE}" ' BEGIN {changed=0; found=0} { if ($0 ~ /^[[:space:]]*ssl_ciphers[[:space:]]+/) { if ($0 != desired) { changed=1 } print desired found=1 next } print $0 } END { if (found==0) { print "" print desired changed=1 } exit(changed ? 0 : 1) } ' "${file_in}" > "${file_out}"; then return 0 else return 1 fi } # --- Pod ops ------------------------------------------------------------------- nginx_test() { local pod="$1" local container="$2" run_ro "kubectl exec -n '${NAMESPACE}' '${pod}' -c '${container}' -- nginx -t" } nginx_reload() { local pod="$1" local container="$2" run_mut "kubectl exec -n '${NAMESPACE}' '${pod}' -c '${container}' -- nginx -s reload" } pod_verify_grep() { local pod="$1" local container="$2" run_ro "kubectl exec -n '${NAMESPACE}' '${pod}' -c '${container}' -- sh -c \"grep -n \\\"ssl_ciphers\\\" '${MOUNTED_VHOST_PATH}' || true\"" } pod_cat_ssl_ciphers() { local pod="$1" local container="$2" run_ro "kubectl exec -n '${NAMESPACE}' '${pod}' -c '${container}' -- sh -c \"grep -n \\\"ssl_ciphers\\\" '${MOUNTED_VHOST_PATH}' | head -n 1 || true\"" } # --- Main ---------------------------------------------------------------------- main() { log "Starting gateway cipher hardening (v2.2 - 2026-02-16)." log "Log file: ${LOG_FILE}" log "Namespace: ${NAMESPACE}" log "ConfigMap: ${CONFIGMAP_NAME} (key: ${CONFIG_KEY})" log "Dry-run: ${DRY_RUN}" log "Rollback: ${ROLLBACK}" check_commands precheck_kube_access precheck_configmap_exists local pod container tmp_cm current_cm_line pod="$(select_gateway_pod)" container="$(select_container_in_pod "${pod}")" log "Discovered inputs:" log " - Gateway pod: ${pod}" log " - Pod container: ${container}" log " - Mounted vhost cfg: ${MOUNTED_VHOST_PATH}" log " - Target ConfigMap: ${CONFIGMAP_NAME} (ns: ${NAMESPACE}, key: ${CONFIG_KEY})" tmp_cm="$(mktemp)" get_cm_key_to_file "${tmp_cm}" current_cm_line="$(extract_ssl_ciphers_line_from_file "${tmp_cm}")" rm -f "${tmp_cm}" || true log "Current status:" if [[ -n "${current_cm_line}" ]]; then log " - ConfigMap ssl_ciphers: ${current_cm_line}" else log " - ConfigMap ssl_ciphers: " fi log " - Desired ssl_ciphers: ${DESIRED_SSL_CIPHERS_LINE}" log " - Pod-mounted ssl_ciphers (first match):" pod_cat_ssl_ciphers "${pod}" "${container}" if [[ "${ROLLBACK}" == "1" ]]; then ask_for_consent "Rollback: restore ConfigMap '${CONFIGMAP_NAME}' from backup and reload nginx in pod '${pod}'." restore_configmap_from_latest_backup nginx_test "${pod}" "${container}" nginx_reload "${pod}" "${container}" pod_verify_grep "${pod}" "${container}" log "Rollback completed." exit 0 fi tmp_cm="$(mktemp)" get_cm_key_to_file "${tmp_cm}" if config_already_compliant_file "${tmp_cm}"; then rm -f "${tmp_cm}" || true log "No change required: ConfigMap already contains the desired ssl_ciphers cipher set (case/order-insensitive match)." log "Activity concluded (no consent required)." exit 0 fi rm -f "${tmp_cm}" || true ask_for_consent "Hardening: update ConfigMap '${CONFIGMAP_NAME}' (key '${CONFIG_KEY}') to enforce desired ssl_ciphers and reload nginx in pod '${pod}'." log "Step 1: Enforcing exact ssl_ciphers line in ConfigMap." local tmp_in tmp_out tmp_in="$(mktemp)" tmp_out="$(mktemp)" get_cm_key_to_file "${tmp_in}" backup_configmap_json if enforce_ssl_ciphers_line "${tmp_in}" "${tmp_out}"; then log "Config updated. Patching ConfigMap..." patch_cm_key_from_file "${tmp_out}" else log "No changes generated by enforcement function (unexpected). Proceeding to nginx validation anyway." fi rm -f "${tmp_in}" "${tmp_out}" || true log "Step 2: nginx syntax test in pod." nginx_test "${pod}" "${container}" log "Step 2: nginx reload in pod (no restart)." nginx_reload "${pod}" "${container}" log "Step 3: Verify ssl_ciphers inside the pod-mounted config." pod_verify_grep "${pod}" "${container}" log "SUCCESS: Completed Steps 1-3." log "Backups/state: ${STATE_DIR}" log "Rollback: ./gateway_cipher_hardening.sh --rollback" } main