Files
proxmox/scripts/nginx-proxy-manager/fix-npmplus-ssl-issues.sh
defiQUG dbd517b279 Sync workspace: config, docs, scripts, CI, operator rules, and submodule pointers.
- Update dbis_core, cross-chain-pmm-lps, explorer-monorepo, metamask-integration, pr-workspace/chains
- Omit embedded publish git dirs and empty placeholders from index

Made-with: Cursor
2026-04-12 06:12:20 -07:00

429 lines
14 KiB
Bash
Executable File

#!/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