Files
proxmox/scripts/verify/audit-npmplus-ssl-all-instances.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

268 lines
11 KiB
Bash
Executable File

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