#!/usr/bin/env bash # check_surface.sh — REMOTE-ONLY public surface / ecosystem scan. # # NOT in default phases. Explicit: # ./taler-monitoring.sh surface # ./taler-monitoring.sh -d hacktivism.ch surface # # Without -d / with generic ecosystem: catalog of taler.net, gnunet.org, # taler-systems.com, taler-ops, mattermost, … # With -d DOMAIN: expand domains.conf stack hosts + catalog entries matching # that domain + common subdomain guesses; port/protocol probes only remote. # # Rules: # - no SSH into targets, no local podman on targets # - ICMP/port alone never proves "down" — confirm via expected protocol # - catalogued host unreachable via protocol → ERROR # - version unknown after probes → WARN # - CVE hit (OSV) for identified software+version → ERROR # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" set_area surface section "surface · remote ecosystem / domain inventory (outside-in only)" CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" # Common ports when scanning a host discovered without explicit list DEFAULT_SCAN_PORTS="${SURFACE_SCAN_PORTS:-22,80,443,993,8443,9418}" PORT_TIMEOUT="${SURFACE_PORT_TIMEOUT:-2}" HTTP_TIMEOUT="${SURFACE_HTTP_TIMEOUT:-12}" # CVE via OSV (public, no key). Disable: SURFACE_CVE=0 : "${SURFACE_CVE:=1}" # nmap OS / service fingerprint (v1.8.0+). Disable: SURFACE_NMAP=0 : "${SURFACE_NMAP:=1}" # OS detection (-O) often needs root; falls back to -sV when denied : "${SURFACE_NMAP_OS:=1}" # Extra ports when -d domain mode DOMAIN_EXTRA_PORTS="${SURFACE_DOMAIN_PORTS:-22,80,443,8443}" declare -A HOST_PORTS # host -> comma ports declare -A HOST_PROTO # host -> expect proto declare -A HOST_LABEL # host -> short label declare -A HOST_EXPECT # host -> 1 if catalogued (must respond) ORDERED_HOSTS=() add_host() { local h="$1" ports="${2:-}" proto="${3:-https}" label="${4:-}" expect="${5:-1}" h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##;s/\.$//') [ -n "$h" ] || return 0 if [ -z "${HOST_PORTS[$h]+x}" ]; then ORDERED_HOSTS+=("$h") HOST_PORTS[$h]="${ports:-443}" HOST_PROTO[$h]="${proto:-https}" HOST_LABEL[$h]="${label:-$h}" HOST_EXPECT[$h]="$expect" else # merge ports local p for p in ${ports//,/ }; do case ",${HOST_PORTS[$h]}," in *",$p,"*) ;; *) HOST_PORTS[$h]="${HOST_PORTS[$h]},$p" ;; esac done [ "${HOST_EXPECT[$h]}" = "1" ] || HOST_EXPECT[$h]="$expect" fi } load_catalog() { local line h ports proto label [ -f "$CATALOG" ] || { warn "catalog" "missing $CATALOG — using empty base list" return 0 } while IFS= read -r line || [ -n "$line" ]; do line=${line%%#*} line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') [ -z "$line" ] && continue # host ports proto label h=$(echo "$line" | awk '{print $1}') ports=$(echo "$line" | awk '{print $2}') proto=$(echo "$line" | awk '{print $3}') label=$(echo "$line" | awk '{print $4}') [ "$ports" = "-" ] && ports="" add_host "$h" "$ports" "${proto:-https}" "${label:-}" 1 done <"$CATALOG" info "catalog" "loaded $CATALOG · ${#ORDERED_HOSTS[@]} hosts" } # Clear inventory (used when switching to domain-only scope) clear_hosts() { ORDERED_HOSTS=() unset HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT declare -gA HOST_PORTS HOST_PROTO HOST_LABEL HOST_EXPECT } # Expand for -d domain: ONLY that domain (remote). No whole-ecosystem catalog. expand_domain_scope() { local d="$1" h d=${d#https://}; d=${d%/} d=${d#http://} info "scope" "domain mode −d $d (remote only · not full ecosystem catalog)" clear_hosts # stack endpoints from applied profile (always expected) for h in \ "${BANK_PUBLIC:-}" \ "${EXCHANGE_PUBLIC:-}" \ "${MERCHANT_PUBLIC:-}" \ "${PAIVANA_PUBLIC:-}" do [ -n "$h" ] || continue add_host "$h" "80,443,$DOMAIN_EXTRA_PORTS" https "stack" 1 done # apex + common subdomains (expected if DNS exists — set expect after DNS in scan, # but catalogued stack already expected; guesses start optional) for sub in "" www bank exchange taler merchant backend shop shops stage \ git docs www2 api static media landing mon401 do if [ -z "$sub" ]; then h="$d" else h="${sub}.${d}" fi # stack hosts already added as expect=1; guesses optional until we promote if [ -z "${HOST_PORTS[$h]+x}" ]; then add_host "$h" "$DOMAIN_EXTRA_PORTS" https "guess" 0 fi done # also pick catalog lines that belong to this domain only if [ -f "$CATALOG" ]; then local line ports proto label while IFS= read -r line || [ -n "$line" ]; do line=${line%%#*} line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') [ -z "$line" ] && continue h=$(echo "$line" | awk '{print $1}') h=$(printf '%s' "$h" | tr 'A-Z' 'a-z' | sed 's#^https\?://##;s#/.*##') if [ "$h" = "$d" ] || [[ "$h" == *".$d" ]]; then ports=$(echo "$line" | awk '{print $2}') proto=$(echo "$line" | awk '{print $3}') label=$(echo "$line" | awk '{print $4}') [ "$ports" = "-" ] && ports="" add_host "$h" "$ports" "${proto:-https}" "${label:-dom}" 1 fi done <"$CATALOG" fi } # Ecosystem mode: full catalog; all entries expected expand_ecosystem_scope() { info "scope" "ecosystem mode (taler.net / gnunet.org / taler-systems.com / taler-ops / mattermost, … · remote only)" local h for h in "${ORDERED_HOSTS[@]}"; do HOST_EXPECT[$h]=1 done } # --- remote probes (no SSH) --- dns_ok() { local h="$1" getent ahosts "$h" >/dev/null 2>&1 || getent hosts "$h" >/dev/null 2>&1 } # TCP connect only (not proof of service) tcp_open() { local h="$1" port="$2" if command -v timeout >/dev/null 2>&1; then timeout "$PORT_TIMEOUT" bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null else bash -c "echo >/dev/tcp/${h}/${port}" 2>/dev/null fi } # ICMP optional — never sole grounds for ERROR ping_host() { local h="$1" ping -c 1 -W 2 "$h" >/dev/null 2>&1 || ping -c 1 -w 2 "$h" >/dev/null 2>&1 } # HTTPS probe: code, server header, version hints, cert subject/dates probe_https() { local h="$1" port="${2:-443}" local url hdr body code server via cert_end subj san url="https://${h}/" [ "$port" != "443" ] && url="https://${h}:${port}/" body=$(mktemp) hdr=$(mktemp) code=$(curl -skS -m "$HTTP_TIMEOUT" -D "$hdr" -o "$body" -w '%{http_code}' \ --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000) server=$(awk 'BEGIN{IGNORECASE=1} /^server:/{sub(/\r$/,""); sub(/^server:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true) via=$(awk 'BEGIN{IGNORECASE=1} /^x-powered-by:/{sub(/\r$/,""); sub(/^[^:]+:[[:space:]]*/,""); print; exit}' "$hdr" 2>/dev/null || true) # cert cert_end=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \ | openssl x509 -noout -enddate 2>/dev/null | sed 's/notAfter=//') subj=$(echo | openssl s_client -servername "$h" -connect "${h}:${port}" 2>/dev/null \ | openssl x509 -noout -subject 2>/dev/null | head -1) # taler version hints in body local taler_hint taler_hint=$(grep -oE 'taler[^"[:space:]]{0,40}|GNU Taler|libeufin|gnunet' "$body" 2>/dev/null | head -3 | tr '\n' ' ' || true) # config JSON version if /config works local cfg_ver="" local ccode ccode=$(curl -skS -m "$HTTP_TIMEOUT" -o "$body" -w '%{http_code}' \ "https://${h}/config" 2>/dev/null || echo 000) if [ "$ccode" = "200" ]; then cfg_ver=$(python3 -c 'import json,sys try: d=json.load(open(sys.argv[1])) print(d.get("version") or d.get("name") or d.get("currency") or "config-json") except Exception: print("")' "$body" 2>/dev/null || true) fi rm -f "$hdr" "$body" printf 'code=%s server=%s powered=%s cert_end=%s cfg=%s hint=%s' \ "$code" "${server:-}" "${via:-}" "${cert_end:-}" "${cfg_ver:-}" "${taler_hint:-}" # return 0 if HTTP answered (any 2xx/3xx/4xx — service is up) case "$code" in 2??|3??|4??) return 0 ;; *) return 1 ;; esac } probe_http() { local h="$1" port="${2:-80}" local url code url="http://${h}/" [ "$port" != "80" ] && url="http://${h}:${port}/" code=$(curl -sS -m "$HTTP_TIMEOUT" -o /dev/null -w '%{http_code}' \ --connect-timeout "$PORT_TIMEOUT" "$url" 2>/dev/null || echo 000) printf 'code=%s' "$code" case "$code" in 2??|3??|4??) return 0 ;; *) return 1 ;; esac } probe_ssh_banner() { local h="$1" port="${2:-22}" local ban ban=$(timeout "$PORT_TIMEOUT" bash -c "exec 3<>/dev/tcp/${h}/${port}; dd bs=256 count=1 <&3 2>/dev/null" 2>/dev/null \ | tr -d '\r' | head -1 || true) printf 'banner=%s' "${ban:-}" [ -n "$ban" ] } # Map Server header → package name guess for OSV guess_package() { local server="$1" local s s=$(printf '%s' "$server" | tr 'A-Z' 'a-z') case "$s" in *nginx*) echo "nginx" ;; *apache*|*httpd*) echo "apache" ;; *caddy*) echo "caddy" ;; *openbsd\ httpd*) echo "openbsd-httpd" ;; *) echo "" ;; esac } extract_version() { local server="$1" # nginx/1.22.1 → 1.22.1 printf '%s' "$server" | sed -n 's/.*\/\([0-9][0-9.]*\).*/\1/p' | head -1 } # OSV / CVE check for software version taken from Server headers. # # Important: headers only expose upstream versions (nginx/1.26.3), never the # Debian package revision (1.26.3-3+deb13u7). Querying OSV ecosystem=Debian # with the bare version yields massive false positives (ancient DEBIAN-CVE-* # with introduced:0 and no fixed event, plus every package revision that # merely *starts with* 1.26.3). # # Policy: # - bare upstream version → upstream SEMVER signal only (WARN by default) # - full Debian package version (contains '-') → Debian OSV, filtered # - SURFACE_CVE=0 disables; SURFACE_CVE_LEVEL=error|warn (default warn for bare) check_cves() { local pkg="$1" ver="$2" host="$3" [ "$SURFACE_CVE" = "1" ] || return 0 [ -n "$pkg" ] && [ -n "$ver" ] || return 0 local level out rc # bare header versions are approximate → WARN unless overridden if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then level="${SURFACE_CVE_LEVEL:-error}" else level="${SURFACE_CVE_LEVEL:-warn}" fi out=$( SURFACE_CVE_PKG="$pkg" SURFACE_CVE_VER="$ver" SURFACE_CVE_HOST="$host" python3 - <<'PY' 2>/dev/null || true import json, os, re, urllib.request pkg = os.environ.get("SURFACE_CVE_PKG", "") ver = os.environ.get("SURFACE_CVE_VER", "") host = os.environ.get("SURFACE_CVE_HOST", "") def ver_tuple(s: str): nums = [int(x) for x in re.findall(r"\d+", s or "")[:5]] return tuple(nums) if nums else () def vt_cmp(a, b): n = max(len(a), len(b)) a = a + (0,) * (n - len(a)) b = b + (0,) * (n - len(b)) return (a > b) - (a < b) def in_semver_events(ver, events): """True if ver is in [introduced, fixed) for SEMVER-like event list.""" vt = ver_tuple(ver) if not vt: return False # process sequential introduced/fixed pairs intro = None for e in events or []: if "introduced" in e: intro = e.get("introduced") elif "fixed" in e or "last_affected" in e: fixed = e.get("fixed") last = e.get("last_affected") lo = ver_tuple("0" if intro in (None, "0") else str(intro)) if vt_cmp(vt, lo) < 0: intro = None continue if fixed is not None: if vt_cmp(vt, ver_tuple(str(fixed))) < 0: return True elif last is not None: if vt_cmp(vt, ver_tuple(str(last))) <= 0: return True intro = None # open-ended introduced without fixed → ignore (never clears; Debian noise) return False def osv_post(body): req = urllib.request.Request( "https://api.osv.dev/v1/query", data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=20) as r: return json.load(r) except Exception: return {} def osv_get(vid): try: with urllib.request.urlopen(f"https://api.osv.dev/v1/vulns/{vid}", timeout=15) as r: return json.load(r) except Exception: return {} bare = not (("-" in ver) or ("+" in ver)) hits = [] if bare: # Upstream signal via CVE records that carry extracted_events for this package # (Server header has no Debian revision — do NOT use ecosystem=Debian). seed = { "nginx": [ "CVE-2025-23419", "CVE-2025-53859", "CVE-2024-7347", "CVE-2024-34161", "CVE-2024-32760", "CVE-2024-31079", "CVE-2024-24989", "CVE-2024-24990", ], "apache": [ "CVE-2024-38474", "CVE-2024-38476", "CVE-2024-38477", "CVE-2023-31122", "CVE-2023-43622", ], "caddy": [ "CVE-2022-29718", "CVE-2023-50463", ], }.get(pkg, []) for vid in seed: doc = osv_get(vid) if not doc: continue ok_hit = False for a in doc.get("affected") or []: # prefer extracted_events (human SEMVER) on GIT/nginx ranges for rg in a.get("ranges") or []: db = rg.get("database_specific") or {} extracted = db.get("extracted_events") or [] if extracted and in_semver_events(ver, extracted): # package name filter when present cpes = db.get("cpe") or [] if isinstance(cpes, str): cpes = [cpes] blob = json.dumps(a).lower() + json.dumps(cpes).lower() if pkg == "nginx" and "nginx" not in blob and "f5" not in blob: continue ok_hit = True break if rg.get("type") == "SEMVER" and in_semver_events(ver, rg.get("events") or []): ok_hit = True break if ok_hit: break if ok_hit: hits.append(vid) else: # Full package version — Debian ecosystem is meaningful data = osv_post({"package": {"name": pkg, "ecosystem": "Debian"}, "version": ver}) for v in data.get("vulns") or []: vid = v.get("id") or "?" actionable = False for a in v.get("affected") or []: for rg in a.get("ranges") or []: events = rg.get("events") or [] has_end = any(("fixed" in e) or ("last_affected" in e) for e in events) if not has_end: continue # open-ended Debian noise if rg.get("type") in ("ECOSYSTEM", "SEMVER"): # version string is full deb version; trust OSV query match # but only if a fixed event exists (actionable) actionable = True break if actionable: break # exact version listed versions = a.get("versions") or [] if ver in versions: actionable = True break if actionable: hits.append(vid) # de-dup preserve order seen = set() uniq = [] for h in hits: if h not in seen: seen.add(h) uniq.append(h) if uniq: print(f"HIT {len(uniq)} " + ",".join(uniq[:12])) else: print("CLEAN") PY ) rc=0 case "$out" in HIT\ *) local n ids n=$(printf '%s' "$out" | awk '{print $2}') ids=$(printf '%s' "$out" | cut -d' ' -f3-) if [ "$level" = "error" ]; then err "cve" "$host $pkg $ver — $n actionable vuln(s)" "$ids" rc=1 else warn "cve" "$host $pkg $ver — $n actionable vuln(s) (header version · not Debian pkg)" "$ids" rc=0 fi ;; CLEAN) if [[ "$ver" == *-* ]] || [[ "$ver" == *+* ]]; then info "cve" "$host $pkg $ver — OSV clean (Debian package version)" else info "cve" "$host $pkg $ver — no actionable upstream CVE for bare Server-header version" fi ;; *) info "cve" "$host $pkg $ver — CVE probe skipped/unavailable" ;; esac return "$rc" } cert_expiry_check() { local h="$1" end="$2" [ -n "$end" ] || return 0 local end_epoch now left days end_epoch=$(date -d "$end" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$end" +%s 2>/dev/null || echo 0) now=$(date +%s) [ "$end_epoch" -gt 0 ] || return 0 left=$((end_epoch - now)) days=$((left / 86400)) if [ "$left" -le 0 ]; then err "tls" "$h certificate EXPIRED" "notAfter=$end" return 1 fi if [ "$days" -le 14 ]; then warn "tls" "$h certificate expires in ${days}d" "notAfter=$end" else info "tls" "$h cert ok · ${days}d left · notAfter=$end" fi return 0 } # nmap OS / service fingerprint (v1.8.0+). # ERROR when fingerprint indicates an OS that is EOL / should be upgraded. # Soft/info when nmap missing or unprivileged OS scan fails. nmap_fingerprint_host() { local h="$1" local xml out osline accuracy eol=0 [ "${SURFACE_NMAP:-1}" = "1" ] || return 0 if ! command -v nmap >/dev/null 2>&1; then warn "nmap" "$h · nmap not installed (apt install nmap) — skip OS fingerprint" return 0 fi xml=$(mktemp) out=$(mktemp) # Prefer OS detection when allowed; always try service version light if [ "${SURFACE_NMAP_OS:-1}" = "1" ] && { nmap -Pn -n -T4 -O --osscan-guess --max-os-tries 1 --host-timeout 45s \ -p 22,80,443,25,465,587,993 "$h" -oX "$xml" >"$out" 2>&1 }; then : else nmap -Pn -n -T4 -sV --version-light --host-timeout 35s \ -p 22,80,443,25,465,587,993 "$h" -oX "$xml" >"$out" 2>&1 || true fi # Parse best OS match from XML osline=$( python3 - "$xml" <<'PY' 2>/dev/null || true import sys, xml.etree.ElementTree as ET path = sys.argv[1] try: root = ET.parse(path).getroot() except Exception: sys.exit(0) best = None best_acc = -1 for osm in root.findall(".//osmatch"): name = osm.get("name") or "" try: acc = int(osm.get("accuracy") or "0") except ValueError: acc = 0 if name and acc >= best_acc: best_acc = acc best = name if best: print(f"{best_acc}|{best}") # also surface product/version from service table svcs = [] for port in root.findall(".//port"): state = (port.find("state").get("state") if port.find("state") is not None else "") if state != "open": continue svc = port.find("service") if svc is None: continue prod = svc.get("product") or svc.get("name") or "" ver = svc.get("version") or "" if prod: svcs.append(f"{prod} {ver}".strip()) if svcs: print("SVC|" + "; ".join(svcs[:8])) PY ) rm -f "$xml" "$out" if [ -z "$osline" ]; then info "nmap" "$h · no OS/service fingerprint (need root for -O, or host filtered)" return 0 fi local osname="" svcinfo="" while IFS= read -r line; do case "$line" in SVC\|*) svcinfo=${line#SVC|} ;; *\|*) accuracy=${line%%|*} osname=${line#*|} ok "nmap-os" "$h · OS guess ${accuracy}% · $osname" ;; esac done <<<"$osline" [ -n "$svcinfo" ] && info "nmap-svc" "$h · $svcinfo" # EOL / upgrade-required OS fingerprints → ERROR case "$(printf '%s' "$osname" | tr 'A-Z' 'a-z')" in *debian*6*|*debian*7*|*debian*8*|*debian*9*|*debian*10*|*debian*\"buster\"*|*debian*buster*) eol=1 ;; *ubuntu*12.04*|*ubuntu*14.04*|*ubuntu*16.04*|*ubuntu*18.04*|*ubuntu*20.04*) eol=1 ;; *centos*[5-7]*|*red\ hat\ enterprise*5*|*red\ hat\ enterprise*6*|*red\ hat\ enterprise*7*) eol=1 ;; *windows\ server\ 2008*|*windows\ server\ 2012*|*windows\ xp*|*windows\ 7*) eol=1 ;; *freebsd\ 1[0-2]*|*freebsd\ [0-9].*) eol=1 ;; esac if [ "$eol" = "1" ]; then err "nmap-upgrade" "$h OS fingerprint indicates EOL / upgrade required" "$osname" return 1 fi if [ -n "$osname" ]; then info "nmap-upgrade" "$h OS fingerprint not on EOL list · $osname" fi return 0 } scan_host() { local h="$1" local ports proto label expect local p open_ports=() any_proto_ok=0 ping_ok=0 dns=0 local probe_detail server_hdr pkg ver ports=${HOST_PORTS[$h]:-443} proto=${HOST_PROTO[$h]:-https} label=${HOST_LABEL[$h]:-$h} expect=${HOST_EXPECT[$h]:-0} set_group "$label" if dns_ok "$h"; then dns=1 ok "dns" "$h resolves" else if [ "$expect" = "1" ]; then err "dns" "$h does not resolve (catalogued)" else info "dns" "$h no resolve (optional guess) — skip" fi return 0 fi if ping_host "$h"; then ping_ok=1 info "ping" "$h ICMP ok" else info "ping" "$h ICMP no reply (not decisive)" fi # port scan (unique ports) local _seen_ports=" " for p in ${ports//,/ }; do [ -n "$p" ] || continue case "$_seen_ports" in *" $p "*) continue ;; esac _seen_ports="$_seen_ports$p " if tcp_open "$h" "$p"; then open_ports+=("$p") info "port" "$h:$p open (TCP)" else info "port" "$h:$p closed/filtered (TCP)" fi done # Protocol verification on open ports (and always try 443/80 for https/http expect) local try_ports=("${open_ports[@]}") if [ ${#try_ports[@]} -eq 0 ]; then # still try expected protocol ports even if scan said closed (scan false negatives) case "$proto" in https) try_ports=(443) ;; http) try_ports=(80) ;; ssh) try_ports=(22) ;; *) try_ports=(443 80) ;; esac fi server_hdr="" ver="" for p in "${try_ports[@]}"; do case "$p" in 443|8443) if probe_detail=$(probe_https "$h" "$p"); then any_proto_ok=1 ok "https" "$h:$p up · $probe_detail" server_hdr=$(printf '%s' "$probe_detail" | sed -n 's/.*server=\([^ ]*\).*/\1/p') # cert local cend cend=$(printf '%s' "$probe_detail" | sed -n 's/.*cert_end=\([^ ]*\).*/\1/p') cert_expiry_check "$h" "$cend" || true local cfg cfg=$(printf '%s' "$probe_detail" | sed -n 's/.*cfg=\([^ ]*\).*/\1/p') if [ -n "$cfg" ]; then info "version" "$h config/api hint: $cfg" ver="$cfg" fi else info "https" "$h:$p no HTTP response · ${probe_detail:-}" fi ;; 80|8080) if probe_detail=$(probe_http "$h" "$p"); then any_proto_ok=1 ok "http" "$h:$p up · $probe_detail" else info "http" "$h:$p no HTTP response" fi ;; 22) if probe_detail=$(probe_ssh_banner "$h" "$p"); then any_proto_ok=1 ok "ssh" "$h:$p banner · $probe_detail" ver=$(printf '%s' "$probe_detail" | sed 's/banner=//') else info "ssh" "$h:$p no SSH banner" fi ;; *) if tcp_open "$h" "$p"; then info "tcp" "$h:$p open · protocol unknown" fi ;; esac done # Version summary if [ -n "$server_hdr" ]; then info "server-header" "$h · $server_hdr" pkg=$(guess_package "$server_hdr") ver_soft=$(extract_version "$server_hdr") if [ -n "$pkg" ] && [ -n "$ver_soft" ]; then info "version" "$h software $pkg $ver_soft (from Server header)" check_cves "$pkg" "$ver_soft" "$h" || true elif [ -n "$server_hdr" ]; then warn "version" "$h Server header present but version unknown · $server_hdr" fi elif [ -n "$ver" ]; then info "version" "$h · $ver" else if [ "$any_proto_ok" = "1" ]; then warn "version" "$h reachable but software version unknown" fi fi # nmap OS / service fingerprint (v1.8.0+) — ERROR on EOL OS nmap_fingerprint_host "$h" || true # Expected service must answer protocol (not just ping/port) # Note: ${array[*]:-x} is NOT valid default syntax (bash treats : as slice). local ports_txt="${open_ports[*]}" ports_txt=${ports_txt:-none} if [ "$expect" = "1" ]; then if [ "$any_proto_ok" = "1" ]; then ok "reachability" "$h catalogued service OK (protocol confirmed)" else err "reachability" "$h catalogued but not reachable via ${proto}/protocol" \ "dns=$dns ping=$ping_ok open_ports=${ports_txt} (ICMP/TCP alone not enough; protocol failed)" fi else if [ "$any_proto_ok" = "1" ]; then info "reachability" "$h optional host responds" else info "reachability" "$h optional · no protocol response (ok)" fi fi } # --- main --- # Scope (remote only): # ./taler-monitoring.sh surface → full ecosystem catalog # ./taler-monitoring.sh -d hacktivism.ch surface → only that domain if [ "${SURFACE_SCOPE:-}" = "ecosystem" ]; then load_catalog expand_ecosystem_scope elif [ "${SURFACE_SCOPE:-}" = "domain" ] || [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then # domain mode: do not load whole ecosystem first CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" expand_domain_scope "${TALER_DOMAIN:-hacktivism.ch}" elif [ "${SURFACE_SCOPE:-auto}" = "auto" ]; then # no -d → ecosystem; with -d → domain (TALER_DOMAIN_FROM_CLI) if [ "${TALER_DOMAIN_FROM_CLI:-0}" = "1" ]; then CATALOG="${SURFACE_CATALOG:-$ROOT/surface-catalog.conf}" expand_domain_scope "$TALER_DOMAIN" else load_catalog expand_ecosystem_scope fi else load_catalog expand_ecosystem_scope fi info "inventory" "${#ORDERED_HOSTS[@]} hosts to probe (remote only · no SSH · no local podman on targets)" info "flags" "SURFACE_CVE=${SURFACE_CVE:-1} SURFACE_NMAP=${SURFACE_NMAP:-1} SURFACE_NMAP_OS=${SURFACE_NMAP_OS:-1} PORT_TIMEOUT=${PORT_TIMEOUT:-2} HTTP_TIMEOUT=${HTTP_TIMEOUT:-12}" for h in "${ORDERED_HOSTS[@]}"; do [ -n "$h" ] || continue scan_host "$h" || true done # mon_disk on the *runner* only (optional) — not remote servers' disks if [ "${SURFACE_CHECK_RUNNER_DISK:-0}" = "1" ]; then set_group disk if declare -F mon_disk_check_host >/dev/null 2>&1; then mon_disk_check_host "runner" || true else warn "disk" "mon_disk_check_host not available" fi fi # summary returns non-zero when FAIL_N>0; do not trip set -e mid-script summary || true if [ "${FAIL_N:-0}" -eq 0 ]; then exit 0 fi exit 1