#!/usr/bin/env bash # Audit all documented NPMplus instances: proxy hosts without certificate_id or with expired certs. # Requires LAN + NPM API auth. Supports both JSON bearer-token auth and the # newer cookie-session auth returned by some NPMplus UIs. # Default: NPM_EMAIL / NPM_PASSWORD for every instance. # Optional per-instance overrides (same email, different password): NPM_PASSWORD_SECONDARY, # NPM_PASSWORD_ALLTRA_HYBX, NPM_PASSWORD_FOURTH, NPM_PASSWORD_MIFOS (fallback: NPM_PASSWORD). # ssl_not_forced = Let's Encrypt cert exists but "Force SSL" is off (common for JSON-RPC; browsers may still use HTTPS). # cert_domain_mismatch = host is bound to a certificate whose SAN/domain list does not cover that hostname. # Tunnel-backed hostnames (proxied CNAME → *.cfargotunnel.com) are excluded from origin-cert findings, # because Cloudflare terminates public TLS and the tunnel origin uses noTLSVerify by design. # Usage: bash scripts/verify/audit-npmplus-ssl-all-instances.sh [--json] 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 JSON_OUT=0 for _a in "$@"; do [[ "$_a" == "--json" ]] && JSON_OUT=1; done _orig_npm_email="${NPM_EMAIL:-}" _orig_npm_password="${NPM_PASSWORD:-}" if [ -f "$PROJECT_ROOT/.env" ]; then set +u # shellcheck source=/dev/null source "$PROJECT_ROOT/.env" set -u fi [ -n "$_orig_npm_email" ] && NPM_EMAIL="$_orig_npm_email" [ -n "$_orig_npm_password" ] && NPM_PASSWORD="$_orig_npm_password" NPM_EMAIL="${NPM_EMAIL:-}" NPM_PASSWORD="${NPM_PASSWORD:-}" if [ -z "$NPM_PASSWORD" ]; then echo "NPM_PASSWORD required (repo .env or export)." >&2 exit 1 fi CF_ZONE_ID="${CLOUDFLARE_ZONE_ID_D_BIS_ORG:-${CLOUDFLARE_ZONE_ID:-}}" cf_api_get() { local path="$1" if [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then curl -s "https://api.cloudflare.com/client/v4${path}" \ -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" elif [ -n "${CLOUDFLARE_API_KEY:-}" ] && [ -n "${CLOUDFLARE_EMAIL:-}" ]; then curl -s "https://api.cloudflare.com/client/v4${path}" \ -H "X-Auth-Email: ${CLOUDFLARE_EMAIL}" \ -H "X-Auth-Key: ${CLOUDFLARE_API_KEY}" else return 1 fi } is_tunnel_backed_host() { local host="$1" [ -n "$CF_ZONE_ID" ] || return 1 local resp resp=$(cf_api_get "/zones/${CF_ZONE_ID}/dns_records?name=${host}" 2>/dev/null || true) echo "$resp" | jq -e ' any(.result[]?; .type == "CNAME" and (.proxied == true) and ((.content // "") | endswith(".cfargotunnel.com")) ) ' >/dev/null 2>&1 } 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 } NPM_CURL_MAX_TIME="${NPM_CURL_MAX_TIME:-120}" curl_npm() { curl -s -k -L --connect-timeout 8 --max-time "$NPM_CURL_MAX_TIME" "$@"; } 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" } auth_api_get() { local npm_url="$1" local token="$2" local cookie_jar="$3" local path="$4" if [ -n "$token" ]; then curl_npm -X GET "$npm_url$path" -H "Authorization: Bearer $token" else curl_npm -b "$cookie_jar" -c "$cookie_jar" -X GET "$npm_url$path" fi } try_base_url() { local base="$1" curl_npm -o /dev/null -w "%{http_code}" "$base/" 2>/dev/null | grep -qE '^(200|301|302|401)$' } pick_working_url() { local ip="$1" local https="https://${ip}:81" local http="http://${ip}:81" if try_base_url "$https"; then echo "$https"; return 0; fi if try_base_url "$http"; then echo "$http"; return 0; fi return 1 } # label|ip — URLs resolved at runtime (http vs https) INSTANCES=( "primary|${IP_NPMPLUS:-192.168.11.167}" "secondary|${IP_NPMPLUS_SECONDARY:-192.168.11.168}" "alltra-hybx|${IP_NPMPLUS_ALLTRA_HYBX:-192.168.11.169}" "fourth-dev|${IP_NPMPLUS_FOURTH:-192.168.11.170}" "mifos|${IP_NPMPLUS_MIFOS:-192.168.11.171}" ) audit_one() { local label="$1" local npm_url="$2" local pass="$3" local auth_json token hosts_json certs_json auth_resp cookie_jar auth_mode tunnel_hosts cookie_jar="$(mktemp)" auth_json=$(jq -n --arg identity "$NPM_EMAIL" --arg secret "$pass" '{identity:$identity,secret:$secret}') auth_resp=$(curl_npm -c "$cookie_jar" -X POST "$npm_url/api/tokens" -H "Content-Type: application/json" -d "$auth_json") token=$(extract_json_token "$auth_resp") if [ -n "$token" ] && [ "$token" != "null" ]; then auth_mode="bearer" elif cookie_has_session "$cookie_jar"; then auth_mode="cookie" else jq -n --arg l "$label" --arg u "$npm_url" \ '{instance:$l, npm_url:$u, status:"auth_failed", issues:[{type:"auth",detail:"no token"}]}' rm -f "$cookie_jar" return 0 fi hosts_json=$(auth_api_get "$npm_url" "$token" "$cookie_jar" "/api/nginx/proxy-hosts") certs_json=$(auth_api_get "$npm_url" "$token" "$cookie_jar" "/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 jq -n --arg l "$label" --arg u "$npm_url" --arg mode "$auth_mode" \ '{instance:$l, npm_url:$u, status:"auth_failed", auth_mode:$mode, issues:[{type:"auth",detail:"authenticated but API payload was not usable"}]}' rm -f "$cookie_jar" return 0 fi tunnel_hosts='[]' while IFS= read -r host; do [ -n "$host" ] || continue if is_tunnel_backed_host "$host"; then tunnel_hosts=$(jq -n --argjson acc "$tunnel_hosts" --arg h "$host" '$acc + [$h]') fi done < <(echo "$hosts_json" | jq -r '.[] | select((.enabled // true) != false) | ((.domain_names // [])[0] // empty)') echo "$hosts_json" "$certs_json" | jq -s --arg l "$label" --arg u "$npm_url" --arg mode "$auth_mode" --argjson tunnel_hosts "$tunnel_hosts" ' .[0] as $hosts | .[1] as $certs | ($certs | if type == "array" then . else [] end) as $cl | ($cl | map({(.id | tostring): .}) | add // {}) as $cmap | (now | floor) as $now | def host_covered_by_cert($host; $names): any(($names // [])[]; . == $host or (startswith("*.") and ($host | endswith("." + .[2:])) and ($host != .[2:]))); def host_uses_tunnel($host): any(($tunnel_hosts // [])[]; . == $host); 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; [ $hosts[] | select((.enabled // true) != false) ] as $hen | [ $hen[] | . as $h | ($h.domain_names // []) | join(",") as $dn | (($h.domain_names // [])[0] // "") as $host0 | ($h.certificate_id // null) as $cid | if host_uses_tunnel($host0) then empty elif $cid == null or $cid == 0 then {domains:$dn, type:"no_certificate", ssl_forced:($h.ssl_forced // false), forward:(($h.forward_scheme//"") + "://" + ($h.forward_host//"") + ":" + (($h.forward_port|tostring)//""))} else ($cmap[($cid|tostring)] // null) as $c | if $c == null then {domains:$dn, type:"missing_cert_record", certificate_id:$cid, ssl_forced:($h.ssl_forced // false)} else parse_exp($c) as $es | epoch_if($es) as $ep | if $ep != null and $ep < $now then {domains:$dn, type:"expired", certificate_id:$cid, expires_on:$es, ssl_forced:($h.ssl_forced // false)} elif (host_covered_by_cert(($h.domain_names // [])[0]; ($c.domain_names // [])) | not) then {domains:$dn, type:"cert_domain_mismatch", certificate_id:$cid, expires_on:$es, cert_domains:($c.domain_names // []), ssl_forced:($h.ssl_forced // false)} elif ($h.ssl_forced // false) != true then {domains:$dn, type:"ssl_not_forced", certificate_id:$cid, expires_on:$es, ssl_forced:false} else empty end end end ] as $issues | { instance: $l, npm_url: $u, auth_mode: $mode, status: "ok", proxy_hosts_enabled: ($hen | length), certificates: ($cl | length), issues: $issues } ' rm -f "$cookie_jar" } results='[]' for row in "${INSTANCES[@]}"; do label="${row%%|*}" ip="${row#*|}" if ! base=$(pick_working_url "$ip"); then piece=$(jq -n --arg l "$label" --arg ip "$ip" \ '{instance:$l, ip:$ip, status:"unreachable", issues:[{type:"unreachable",detail:"no HTTP/HTTPS on :81"}]}') results=$(jq -n --argjson acc "$results" --argjson p "$(echo "$piece" | jq -c .)" '$acc + [$p]') continue fi pw=$(password_for_label "$label") piece=$(audit_one "$label" "$base" "$pw") results=$(jq -n --argjson acc "$results" --argjson p "$(echo "$piece" | jq -c .)" '$acc + [$p]') done if [ "$JSON_OUT" = 1 ]; then echo "$results" | jq . exit 0 fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "NPMplus SSL audit (all instances)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "$results" | jq -r '.[] | "── \(.instance) (\(.npm_url // .ip // "?")) ──", (if .status == "unreachable" then " UNREACHABLE: \(.issues[0].detail // .status)" elif .status == "auth_failed" then " AUTH FAILED (wrong NPM_EMAIL/NPM_PASSWORD for this UI?)" else " Proxy hosts (enabled): \(.proxy_hosts_enabled // 0) | Certificate objects: \(.certificates // 0)", (if (.issues | length) == 0 then " No issues: all enabled hosts have active cert + ssl_forced." else (.issues[] | " • [\(.type)] \(.domains)\(if .certificate_id then " (cert_id=\(.certificate_id))" else "" end)\(if .expires_on then " expires=\(.expires_on)" else "" end)\(if .cert_domains then " cert_domains=\(.cert_domains|join(","))" else "" end)\(if .forward then " → \(.forward)" else "" end)") end) end), ""' issue_total=$(echo "$results" | jq '[.[].issues | length] | add') if [ "${issue_total:-0}" -eq 0 ]; then echo "Summary: no SSL gaps reported (or all instances unreachable/auth failed — see above)." else echo "Summary: $issue_total issue row(s) across instances (no_cert, expired, cert_domain_mismatch, ssl_not_forced, missing_cert_record)." fi echo "" echo "Fix: NPM UI → Proxy Hosts → SSL, or scripts/nginx-proxy-manager/fix-npmplus-ssl-issues.sh per NPM_URL."