#!/usr/bin/env bash # Fix NPMplus SSL gaps reported by audit-npmplus-ssl-all-instances.sh (primary instance by default). # - Hosts with a cert but ssl_forced=false: enable Force SSL + HSTS only for non-RPC endpoints (see should_force_ssl). # - Hosts with no cert: request Let's Encrypt cert via NPM API, assign to host (ssl_forced per same rule). # - Hosts with expired certs: request a fresh cert (or reuse an already-valid one) and reassign it. # - Hosts bound to the wrong certificate: request/reuse a matching cert and reassign it. # Supports both JSON bearer-token auth and cookie-session auth returned by some # NPMplus UIs. # Skips: disabled hosts; domain names containing * (wildcard — request certs in UI); dry-run. # # Usage: bash scripts/nginx-proxy-manager/fix-npmplus-ssl-issues.sh [--dry-run] # Env: NPM_URL (default https://IP_NPMPLUS:81), NPM_EMAIL, NPM_PASSWORD, NPM_CURL_MAX_TIME # NPM_SSL_FIX_SLEEP_LE=5 seconds between LE certificate requests (default 5) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" # shellcheck source=/dev/null source "${PROJECT_ROOT}/config/ip-addresses.conf" 2>/dev/null || true DRY_RUN=0 for _a in "$@"; do [[ "$_a" == "--dry-run" ]] && DRY_RUN=1; done _orig_npm_url="${NPM_URL:-}" _orig_npm_email="${NPM_EMAIL:-}" _orig_npm_password="${NPM_PASSWORD:-}" _orig_npm_instance_label="${NPM_INSTANCE_LABEL:-}" if [ -f "$PROJECT_ROOT/.env" ]; then set +u # shellcheck source=/dev/null source "$PROJECT_ROOT/.env" set -u fi [ -n "$_orig_npm_url" ] && NPM_URL="$_orig_npm_url" [ -n "$_orig_npm_email" ] && NPM_EMAIL="$_orig_npm_email" [ -n "$_orig_npm_password" ] && NPM_PASSWORD="$_orig_npm_password" [ -n "$_orig_npm_instance_label" ] && NPM_INSTANCE_LABEL="$_orig_npm_instance_label" # shellcheck source=/dev/null source "${PROJECT_ROOT}/config/ip-addresses.conf" 2>/dev/null || true NPM_URL="${NPM_URL:-https://${IP_NPMPLUS:-192.168.11.167}:81}" NPM_EMAIL="${NPM_EMAIL:-}" NPM_CURL_MAX_TIME="${NPM_CURL_MAX_TIME:-300}" NPM_SSL_FIX_SLEEP_LE="${NPM_SSL_FIX_SLEEP_LE:-5}" password_for_label() { case "$1" in primary) echo "${NPM_PASSWORD_PRIMARY:-${NPM_PASSWORD:-}}" ;; secondary) echo "${NPM_PASSWORD_SECONDARY:-${NPM_PASSWORD:-}}" ;; alltra-hybx) echo "${NPM_PASSWORD_ALLTRA_HYBX:-${NPM_PASSWORD:-}}" ;; fourth-dev) echo "${NPM_PASSWORD_FOURTH:-${NPM_PASSWORD:-}}" ;; mifos) echo "${NPM_PASSWORD_MIFOS:-${NPM_PASSWORD:-}}" ;; *) echo "${NPM_PASSWORD:-}" ;; esac } instance_label_from_url() { local url="${1:-}" local label="${NPM_INSTANCE_LABEL:-}" if [ -n "$label" ]; then echo "$label" return 0 fi case "$url" in *"${IP_NPMPLUS_SECONDARY:-192.168.11.168}"*) echo "secondary" ;; *"${IP_NPMPLUS_ALLTRA_HYBX:-192.168.11.169}"*) echo "alltra-hybx" ;; *"${IP_NPMPLUS_FOURTH:-192.168.11.170}"*) echo "fourth-dev" ;; *"${IP_NPMPLUS_MIFOS:-192.168.11.171}"*) echo "mifos" ;; *) echo "primary" ;; esac } NPM_INSTANCE_LABEL="$(instance_label_from_url "$NPM_URL")" NPM_PASSWORD="$(password_for_label "$NPM_INSTANCE_LABEL")" if [ -z "$NPM_PASSWORD" ]; then echo "NPM_PASSWORD required (.env or export)." >&2 exit 1 fi curl_npm() { curl -s -k -L --connect-timeout 10 --max-time "$NPM_CURL_MAX_TIME" "$@"; } AUTH_TOKEN="" AUTH_COOKIE_JAR="" cleanup_auth() { [ -n "${AUTH_COOKIE_JAR:-}" ] && [ -f "$AUTH_COOKIE_JAR" ] && rm -f "$AUTH_COOKIE_JAR" } trap cleanup_auth EXIT extract_json_token() { local body="${1:-}" echo "$body" | jq -r '.token // empty' 2>/dev/null || true } cookie_has_session() { local jar="$1" [ -f "$jar" ] && grep -q $'\ttoken\t' "$jar" } npm_api() { if [ -n "$AUTH_TOKEN" ]; then curl_npm -H "Authorization: Bearer $AUTH_TOKEN" "$@" else curl_npm -b "$AUTH_COOKIE_JAR" -c "$AUTH_COOKIE_JAR" "$@" fi } # 0 = force SSL OK for this host; 1 = keep ssl_forced false (JSON-RPC / WS edge) should_force_ssl() { local domain="${1,,}" local port="${2:-0}" case "$port" in 8545|8546) return 1 ;; esac [[ "$domain" == *"*"* ]] && return 1 if [[ "$domain" =~ ^(rpc\.|ws\.|wss\.) ]]; then return 1; fi if [[ "$domain" =~ rpc-http- ]]; then return 1; fi if [[ "$domain" =~ rpc-ws- ]]; then return 1; fi if [[ "$domain" =~ rpc-core ]]; then return 1; fi if [[ "$domain" =~ rpc-fireblocks ]]; then return 1; fi if [[ "$domain" =~ rpc\.public-0138 ]]; then return 1; fi if [[ "$domain" == "rpc.d-bis.org" || "$domain" == "rpc2.d-bis.org" ]]; then return 1; fi if [[ "$domain" =~ \.tw-core\. ]]; then return 1; fi if [[ "$domain" =~ ^rpc\.defi-oracle ]]; then return 1; fi if [[ "$domain" =~ ^wss\.defi-oracle ]]; then return 1; fi return 0 } log() { echo "[fix-npmplus-ssl] $*"; } try_connect() { curl_npm -o /dev/null -w "%{http_code}" "$1/" 2>/dev/null | grep -qE '^(200|301|302|401)$' } if ! try_connect "$NPM_URL"; then http_url="${NPM_URL/https:/http:}" try_connect "$http_url" && NPM_URL="$http_url" || { echo "Cannot reach NPM at $NPM_URL"; exit 1; } fi AUTH_JSON=$(jq -n --arg identity "$NPM_EMAIL" --arg secret "$NPM_PASSWORD" '{identity:$identity,secret:$secret}') AUTH_COOKIE_JAR="$(mktemp)" AUTH_RESPONSE=$(curl_npm -c "$AUTH_COOKIE_JAR" -X POST "$NPM_URL/api/tokens" -H "Content-Type: application/json" -d "$AUTH_JSON") AUTH_TOKEN=$(extract_json_token "$AUTH_RESPONSE") if [ -z "$AUTH_TOKEN" ] || [ "$AUTH_TOKEN" = "null" ]; then AUTH_TOKEN="" fi if [ -z "$AUTH_TOKEN" ] && ! cookie_has_session "$AUTH_COOKIE_JAR"; then echo "NPM auth failed for $NPM_URL" >&2 exit 1 fi HOSTS_JSON=$(npm_api -X GET "$NPM_URL/api/nginx/proxy-hosts") CERTS_JSON=$(npm_api -X GET "$NPM_URL/api/nginx/certificates") if ! echo "$HOSTS_JSON" | jq -e 'type == "array"' >/dev/null 2>&1 || \ ! echo "$CERTS_JSON" | jq -e 'type == "array"' >/dev/null 2>&1; then echo "NPM auth succeeded but proxy-host or certificate API payload was not usable for $NPM_URL" >&2 exit 1 fi # NPM PUT rejects full GET payloads ("additional properties"); use minimal SSL update for cert assignment. cert_id_for_domain() { local domain="$1" echo "$CERTS_JSON" | jq -r --arg d "$domain" ' def parse_exp($c): ($c.expires_on // $c.meta.letsencrypt_expiry // $c.meta.expires_on // "") | tostring; def epoch_if($s): if $s == "" or $s == "null" then null else ($s | gsub(" "; "T") | . + "Z" | fromdateiso8601? // null) end; [ .[] | select(.domain_names | type == "array" and any(.[]; . == $d)) | {id, expires_at:(epoch_if(parse_exp(.)))} ] | map(select(.expires_at == null or .expires_at > now)) | .[0].id // empty ' } cert_is_expired() { local cert_id="$1" echo "$CERTS_JSON" | jq -e --argjson cid "$cert_id" ' def parse_exp($c): ($c.expires_on // $c.meta.letsencrypt_expiry // $c.meta.expires_on // "") | tostring; def epoch_if($s): if $s == "" or $s == "null" then null else ($s | gsub(" "; "T") | . + "Z" | fromdateiso8601? // null) end; [ .[] | select(.id == $cid) ][0] as $c | if $c == null then false else (epoch_if(parse_exp($c)) as $ep | ($ep != null and $ep < now)) end ' >/dev/null 2>&1 } cert_matches_domain() { local cert_id="$1" local domain="$2" echo "$CERTS_JSON" | jq -e --argjson cid "$cert_id" --arg d "$domain" ' def host_covered_by_cert($host; $names): any(($names // [])[]; . == $host or (startswith("*.") and ($host | endswith("." + .[2:])) and ($host != .[2:]))); [ .[] | select(.id == $cid) ][0] as $c | if $c == null then false else host_covered_by_cert($d; ($c.domain_names // [])) end ' >/dev/null 2>&1 } put_host_ssl() { local hid="$1" local force="$2" local body resp upd cid body=$(echo "$HOSTS_JSON" | jq --argjson id "$hid" --argjson force "$force" ' [.[] | select(.id == $id)][0] | if . == null then empty else .ssl_forced = $force | .http2_support = true | .hsts_enabled = $force | .hsts_subdomains = $force end ') if [ -z "$body" ] || [ "$body" = "null" ]; then log "host id $hid not found in snapshot" return 1 fi if [ "$DRY_RUN" = 1 ]; then log "dry-run: PUT proxy-hosts/$hid ssl_forced=$force" return 0 fi resp=$(npm_api -X PUT "$NPM_URL/api/nginx/proxy-hosts/$hid" \ -H "Content-Type: application/json" -d "$body") if echo "$resp" | jq -e '.id' >/dev/null 2>&1; then return 0 fi cid=$(echo "$HOSTS_JSON" | jq -r --argjson id "$hid" '.[] | select(.id == $id) | .certificate_id // empty') if [ -z "$cid" ] || [ "$cid" = "null" ]; then log "PUT failed and no certificate_id for host $hid: $(echo "$resp" | head -c 250)" return 1 fi upd=$(jq -n --argjson cid "$cid" --argjson force "$force" \ '{certificate_id:$cid, ssl_forced:$force, http2_support:true, hsts_enabled:$force, hsts_subdomains:$force}') resp=$(npm_api -X PUT "$NPM_URL/api/nginx/proxy-hosts/$hid" \ -H "Content-Type: application/json" -d "$upd") if echo "$resp" | jq -e '.id' >/dev/null 2>&1; then return 0 fi log "PUT fallback failed: $(echo "$resp" | head -c 250)" return 1 } assign_minimal_ssl() { local hid="$1" local cid="$2" local force="$3" local upd uresp upd=$(jq -n --argjson cid "$cid" --argjson f "$force" \ '{certificate_id:$cid, ssl_forced:$f, http2_support:true, hsts_enabled:$f, hsts_subdomains:$f}') uresp=$(npm_api -X PUT "$NPM_URL/api/nginx/proxy-hosts/$hid" \ -H "Content-Type: application/json" -d "$upd") if echo "$uresp" | jq -e '.id' >/dev/null 2>&1; then return 0 fi log "minimal PUT failed for host $hid: $(echo "$uresp" | head -c 280)" return 1 } prepare_host_for_certificate_request() { local hid="$1" local sf="$2" local want_force="$3" local domain="$4" if [[ "$sf" == "$want_force" ]]; then return 0 fi log "pre-align SSL mode: $domain (host $hid, ssl_forced=$want_force)" if put_host_ssl "$hid" "$want_force"; then HOSTS_JSON=$(npm_api -X GET "$NPM_URL/api/nginx/proxy-hosts") return 0 fi return 1 } request_cert_and_assign() { local hid="$1" local domain="$2" local force="$3" local cert_resp new_id existing_id if [ "$DRY_RUN" = 1 ]; then log "dry-run: POST certificates + assign host $hid domain=$domain force=$force" return 0 fi existing_id=$(cert_id_for_domain "$domain") new_id="" if [ -n "$existing_id" ] && [ "$existing_id" != "null" ]; then log "reuse existing cert id=$existing_id for $domain" new_id="$existing_id" else cert_resp=$(npm_api -X POST "$NPM_URL/api/nginx/certificates" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg domain "$domain" '{domain_names:[$domain], provider:"letsencrypt"}')") new_id=$(echo "$cert_resp" | jq -r '.id // empty') if [ -z "$new_id" ] || [ "$new_id" = "null" ]; then log "cert request failed for $domain: $(echo "$cert_resp" | jq -c '.' 2>/dev/null | head -c 350)" return 1 fi fi if assign_minimal_ssl "$hid" "$new_id" "$force"; then CERTS_JSON=$(npm_api -X GET "$NPM_URL/api/nginx/certificates") return 0 fi return 1 } n_force=0 n_force_fail=0 n_cert=0 n_cert_fail=0 n_skip=0 while IFS= read -r row; do [ -z "$row" ] && continue hid=$(echo "$row" | jq -r '.id') domain=$(echo "$row" | jq -r '.domain_names[0] // empty') cid=$(echo "$row" | jq -r '.certificate_id // 0') sf=$(echo "$row" | jq -r '.ssl_forced // false') en=$(echo "$row" | jq -r '.enabled // true') port=$(echo "$row" | jq -r '.forward_port // 0') [[ "$en" == "false" ]] && continue [ -z "$domain" ] || [ "$domain" = "null" ] && continue if should_force_ssl "$domain" "$port"; then want_force=true else want_force=false fi if [[ "$cid" == "null" ]] || [[ -z "$cid" ]] || [[ "$cid" == "0" ]]; then if [[ "$domain" == *"*"* ]]; then log "skip wildcard (request cert in UI): $domain" n_skip=$((n_skip + 1)) continue fi log "request cert: $domain (host $hid, ssl_forced=$want_force)" if request_cert_and_assign "$hid" "$domain" "$want_force" "$row"; then n_cert=$((n_cert + 1)) log "ok cert+assign: $domain" else n_cert_fail=$((n_cert_fail + 1)) fi if [ "$DRY_RUN" = 0 ]; then sleep "$NPM_SSL_FIX_SLEEP_LE" fi continue fi if cert_is_expired "$cid"; then if [[ "$domain" == *"*"* ]]; then log "skip expired wildcard (renew in UI): $domain" n_skip=$((n_skip + 1)) continue fi if ! prepare_host_for_certificate_request "$hid" "$sf" "$want_force" "$domain"; then n_cert_fail=$((n_cert_fail + 1)) log "FAIL pre-align SSL mode before renew: $domain" continue fi log "renew expired cert: $domain (host $hid, cert $cid, ssl_forced=$want_force)" if request_cert_and_assign "$hid" "$domain" "$want_force"; then n_cert=$((n_cert + 1)) log "ok renewed cert: $domain" else n_cert_fail=$((n_cert_fail + 1)) log "FAIL renew: $domain" fi if [ "$DRY_RUN" = 0 ]; then sleep "$NPM_SSL_FIX_SLEEP_LE" fi continue fi if ! cert_matches_domain "$cid" "$domain"; then if [[ "$domain" == *"*"* ]]; then log "skip mismatched wildcard cert (fix in UI): $domain" n_skip=$((n_skip + 1)) continue fi if ! prepare_host_for_certificate_request "$hid" "$sf" "$want_force" "$domain"; then n_cert_fail=$((n_cert_fail + 1)) log "FAIL pre-align SSL mode before mismatched-cert fix: $domain" continue fi log "replace mismatched cert: $domain (host $hid, cert $cid, ssl_forced=$want_force)" if request_cert_and_assign "$hid" "$domain" "$want_force"; then n_cert=$((n_cert + 1)) log "ok reassigned matching cert: $domain" else n_cert_fail=$((n_cert_fail + 1)) log "FAIL reassign mismatched cert: $domain" fi if [ "$DRY_RUN" = 0 ]; then sleep "$NPM_SSL_FIX_SLEEP_LE" fi continue fi if [[ "$sf" == "true" ]]; then continue fi # Has cert, ssl not forced — align with want_force if [[ "$want_force" == "false" ]]; then n_skip=$((n_skip + 1)) continue fi log "enable Force SSL: $domain (host $hid, cert $cid)" if put_host_ssl "$hid" true; then n_force=$((n_force + 1)) log "ok: $domain" else n_force_fail=$((n_force_fail + 1)) log "FAIL: $domain" fi done < <(echo "$HOSTS_JSON" | jq -c '.[]') log "done: force_ssl_ok=$n_force force_ssl_fail=$n_force_fail new_certs_ok=$n_cert new_certs_fail=$n_cert_fail skipped_rpc_or_wildcard=$n_skip dry_run=$DRY_RUN" if [ "$DRY_RUN" = 1 ]; then log "Re-run without --dry-run to apply." fi