taler-monitoring/check_surface.sh
Hernâni Marques e7a3cd558f
release 1.8.0: simplified mon pages + surface nmap + mail hosts
Public layout: only taler-monitoring-surface(+_err) for ecosystem
software/versions; nine landing stacks keep /monitoring(+_err).
Fold mail (firefly.gnunet.org, anastasis.taler-systems.com) and
mattermost into the surface job; deprecate separate mon pages/timers.
nmap OS fingerprinting; package-behind defaults to ERROR.
2026-07-19 00:58:09 +02:00

802 lines
26 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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