diff --git a/README.md b/README.md
index f2a57d4..2fb5ae1 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,5 @@
# taler-monitoring
-**Product name:** **LLM** = **Lightweight Monitoring** (Taler-stack watcher UI/daemon).
-Git/CLI tree remains `taler-monitoring` / `taler-monitord`. Live stacks page: https://hacktivism.ch/stacks/
-
Checks for **GNU Taler** stacks (bank, exchange, merchant; optional surface inventory).
| Mode | What |
@@ -15,7 +12,7 @@ Outside-only example: `./taler-monitoring.sh -d demo.taler.net urls`
Surface (ecosystem + mail + mattermost + versions): `./taler-monitoring.sh surface mattermost mail versions`
Full local GOA example: `./taler-monitoring.sh -d hacktivism.ch urls inside versions`
-Repo: https://git.hacktivism.ch/hernani/taler-monitoring · tree: `~/src/taler/taler-monitoring`
+Repo: https://git.hacktivism.ch/hernani/taler-monitoring · tree: `~/src/taler-monitoring`
Version: `./taler-monitoring.sh --ver` (or `--version` / `-V`)
@@ -178,31 +175,10 @@ STATS_STALE_SECS=900 STATS_FAIL_SECS=3600 ./taler-monitoring.sh -d stage.lefranc
# --- Full load (GOA / hacktivism) ---
# all = urls + inside + versions + sanity + e2e
-# full = all + server + ladder + auth401 (long; needs secrets / SECRETS_ROOT)
+# full = all + server + ladder + auth401 (long; needs secrets.env)
./taler-monitoring.sh -d hacktivism.ch full
./taler-monitoring.sh -d hacktivism.ch urls inside versions sanity server e2e ladder auth401
-# --- GOA payment / withdraw tests (hacktivism) ---
-# Core e2e: bank credit → withdraw ladder → merchant pays (+ optional paivana GOA:4200)
-./taler-monitoring.sh -d hacktivism.ch e2e
-# Only two small bank withdraws (diagnose confirm path), tiny pay:
-E2E_VARIABLE=1 E2E_WITHDRAW_VALUES='20 20' E2E_PAY_VALUES='0.01' \
- E2E_BANK_WITHDRAW_MAX=2 E2E_TOKEN_ENABLE=0 RUN_TIMEOUT=0 \
- ./taler-monitoring.sh -d hacktivism.ch e2e
-# Moderated classic ladder (withdraw+pay, timings in log; not full 23-rung):
-LADDER_STEPS=8 LADDER_MAX_AMOUNT=5000 LADDER_MIN_AMOUNT=1 RUN_TIMEOUT=0 \
- ./taler-monitoring.sh -d hacktivism.ch ladder
-# Same ladder under legacy phase name:
-./taler-monitoring.sh -d hacktivism.ch goa-ladder
-# Hunt highest withdrawable / payable (slow; set RUN_TIMEOUT=0):
-RUN_TIMEOUT=0 ./taler-monitoring.sh -d hacktivism.ch max-ladder
-# Merchant Basic-auth matrix (may create throwaway instance):
-./taler-monitoring.sh -d hacktivism.ch auth401
-# Public URL + latency only (no wallet):
-./taler-monitoring.sh -d hacktivism.ch urls
-# Newer wallet-cli (optional):
-# WALLET_CLI=$HOME/…/taler-wallet-cli.mjs ./taler-monitoring.sh -d hacktivism.ch e2e
-
# SPA pin after selfbuild-webui.sh (container):
EXPECT_WEBUI_VERSION=1.6.11 EXPECT_WEBUI_OVERLAY=selfbuild-v1.6.11 \
WEBUI_OVERLAY_DENY='c778af04|master-11340' \
@@ -249,12 +225,12 @@ stage.lefrancpaysan.ch stage.bank.… stage.exchange.… stage.monnaie.… T
| Field | Meaning |
|-------|---------|
| **name** | `-d NAME` / `TALER_DOMAIN` match (add alias lines as needed) |
-| **bank** | libeufin / `x-taler-bank` host, or `-` / `ebics` when IBAN/EBICS only (no public `bank.*`) |
+| **bank** | libeufin/bank public host or `https://…` |
| **exchange** | exchange public host or URL |
| **merchant** | merchant **backend** (SPA/API), e.g. `my.taler-ops.ch` |
| **currency** | `GOA` / `KUDOS` / `CHF` / … |
| **local** | `1` = koopa SSH stack; `0` = public only |
-| **landing** | `1` = custom `/intro` (GOA + FrancPaysan/TESTPAYSAN stage only); `0` = no intro |
+| **landing** | `1` = check `/intro` landings + assets; `0` = skip (TOPS / mytops) |
| **lang** | UI language **`en`** / **`fr`** / **`de`** when `TALER_MON_LANG_SET≠1`. Stock profiles use **en** or **fr** only — **`de` is never a default** (opt-in via `--lang de` or an explicit profile edit). |
| **locale** | Regional l10n (BCP 47): **`fr-CH`** for FrancPaysan (Suisse romande); **`de-CH`** for other Swiss stacks. Aliases `ch-FR` / `ch-DE` accepted. Not the same as UI `lang`. |
| **canonical** | optional label after alias (e.g. `my.taler-ops.ch` → `taler-ops.ch`) |
@@ -283,18 +259,15 @@ Helpers in `lib.sh`: `set_taler_stack`, `load_domain_profile`, `apply_taler_doma
| Domain / alias | Bank | Exchange | Merchant | Currency |
|----------------|------|----------|----------|----------|
| `hacktivism.ch` / `koopa` | bank.hacktivism.ch | exchange.hacktivism.ch | taler.hacktivism.ch | GOA |
-| `taler.net` / `demo.taler.net` | bank.demo.taler.net | exchange.demo.taler.net | backend.demo.taler.net | KUDOS · **no custom /intro** |
+| `taler.net` / `demo.taler.net` | bank.demo.taler.net | exchange.demo.taler.net | backend.demo.taler.net | KUDOS |
| `test.taler.net` | bank.test… | exchange.test… | backend.test… | **TESTKUDOS** · no landings |
| `lefrancpaysan.ch` | bank.… | exchange.… | **monnaie.…** (merchant) | report-only · no landings |
| **`stage.lefrancpaysan.ch`** | stage.bank.… | stage.exchange.… | **stage.monnaie.…** | **TESTPAYSAN** · landings · **inside via stagepaysan** |
-| `taler-ops.ch` / `my.taler-ops.ch` | *(EBICS — no public bank.\*)* | exchange.taler-ops.ch | **my.taler-ops.ch** | CHF · **no landings** · auto `CHECK_BANK=0` |
-| `stage.taler-ops.ch` | *(EBICS)* | exchange.stage… | **my.stage…** | CHF · **no landings** · auto `CHECK_BANK=0` |
+| `taler-ops.ch` / `my.taler-ops.ch` | bank.taler-ops.ch | exchange.taler-ops.ch | **my.taler-ops.ch** | CHF · **no landings** |
+| `stage.taler-ops.ch` | bank.stage… | exchange.stage… | **my.stage…** | CHF · **no landings** |
| unknown | bank.DOMAIN | exchange.DOMAIN | my.DOMAIN then probe | any · no landings |
-**Custom /intro landings:** only **GOA** (hacktivism) and **FrancPaysan / TESTPAYSAN stage**. Demo, test, regio, TOPS: no custom intro (`CHECK_LANDING=0`).
-
-**Bank backend:** after domain apply, the suite reads exchange `/keys` `payto://` methods.
-`x-taler-bank` → public bank checks on. **IBAN-only (EBICS / TOPS)** → `TALER_BANK_BACKEND=ebics`, `CHECK_BANK=0` (urls/sanity skip bank; e2e/ladder exit early). Override: `CHECK_BANK_SET=1` / `TALER_BANK_BACKEND_SET=1`.
+**Landings:** GOA, stage LFP (`TESTPAYSAN`), and optionally demo use public `/intro` pages. **taler-ops.ch does not** — `CHECK_LANDING=0` skips intro crawl.
**SSH:**
| Stack | Host alias | User | Scope |
diff --git a/VERSION b/VERSION
index 5db08bf..dd43a14 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.27.0
+1.26.1
diff --git a/VERSIONS.md b/VERSIONS.md
index f723582..cb1174e 100644
--- a/VERSIONS.md
+++ b/VERSIONS.md
@@ -17,7 +17,6 @@ Git tags: `vMAJOR.FEATURE.FIX` (e.g. `v1.8.0`). File `VERSION` omits the `v` pre
| Tag | Date (UTC) | Notes |
|-----|------------|--------|
-| **v1.27.0** | 2026-09-05 | **Feature / LLM:** `taler-monitord` + stack-status HTML/JSON; services/ports catalogs; EBICS vs x-taler-bank detect + check skips; regio FLODOS ladder/e2e; ansible-smoke podman tracks; mail ipv6/rdns + taler-ops catalog. |
| **v1.26.1** | 2026-08-31 | **Bugfix:** public order settle accepts `StatusPaidResponse` (`refunded`/`refund_taken`); `paid`/`order_status` not on public GET. |
| **v1.26.0** | 2026-08-30 | **Feature:** `--wallet-db PATH` / `--wallet-migrate` (taler-wallet-cli / taler-harness names) for e2e, ladder, zero-pay. TESTPAYSAN: one Quadlet `stage-testpaysan`; e2e token-pay via env `E2E_TOKEN_*` / `--choice-index`. Stack READMEs `README_testpaysan.md` / `README_mytops.md`. |
| **v1.24.0** | 2026-08-30 | **Feature / zero-pay:** public-template pays as phase `zero-pay` (`check_zero_pay.sh`). Default merchant **stage.my.taler-ops.ch** (CHF:0 stickers). Wrapper `pay_talersticker_zero.sh` COUNT=100. TESTPAYSAN: empty POST `{}` + auto-confirm of bank wires (host `auto-confirm-withdrawals`). Not in default 4h PHASES (use COUNT=1). |
diff --git a/check_amount_ladder.sh b/check_amount_ladder.sh
index f326349..36c0972 100755
--- a/check_amount_ladder.sh
+++ b/check_amount_ladder.sh
@@ -41,11 +41,6 @@ load_monitoring_secrets_env 2>/dev/null || true
# Area ladder.* — withdraw/pay amount ladder (any EXPECT_CURRENCY / stack)
# Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report
set_area ladder
-if [ "${CHECK_BANK:-1}" != "1" ] || [ "${TALER_BANK_BACKEND:-}" = "ebics" ]; then
- set_group plan
- info "ladder" "skipped — backend=${TALER_BANK_BACKEND:-ebics} (needs public x-taler-bank)"
- exit 0
-fi
set_group plan
SECTION_T0=$(date +%s)
now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; }
@@ -133,16 +128,6 @@ INST="${MERCHANT_INSTANCE}"
# Uses bank max_wire_transfer_amount and exchange min denom when still on GOA defaults.
apply_ladder_stack_defaults() {
[ "${LADDER_STACK_AUTO}" = "1" ] || return 0
- if [ "${CUR}" = "FLODOS" ] || [[ "${TALER_DOMAIN:-}" == *regio.taler.net* ]] || [ "${TALER_DOMAIN:-}" = "regio" ]; then
- CUR="FLODOS"
- # demo bank: keep ladder inside a small funded pool (override with LADDER_MAX_AMOUNT=)
- if [ "${LADDER_MAX_AMOUNT}" = "4503599627370496" ] || [ "${LADDER_MAX_AMOUNT}" = "${GOA_LADDER_CEILING:-}" ]; then
- LADDER_MAX_AMOUNT="${REGIO_LADDER_MAX:-20}"
- fi
- LADDER_MIN_AMOUNT="${LADDER_MIN_AMOUNT:-0.02}"
- info "ladder stack" "FLODOS/regio · max=${CUR}:${LADDER_MAX_AMOUNT} min=${CUR}:${LADDER_MIN_AMOUNT}"
- return 0
- fi
if [ "${CUR}" != "TESTPAYSAN" ]; then
case "${TALER_DOMAIN:-}" in
stage.lefrancpaysan.ch|stage.bank.lefrancpaysan.ch|stage.exchange.lefrancpaysan.ch|stage.monnaie.lefrancpaysan.ch)
@@ -274,28 +259,6 @@ resolve_explorer_pw() {
return 0
fi
- # regio.taler.net (FLODOS) — dedicated pool user, not admin (override default EXP_USER=explorer)
- if [ "${CUR:-${EXPECT_CURRENCY:-}}" = "FLODOS" ] \
- || [[ "${TALER_DOMAIN:-}" == *regio.taler.net* ]] || [ "${TALER_DOMAIN:-}" = "regio" ]; then
- if [ -n "${REGIO_BANK_EXPLORER_USER_FILE:-}" ] && [ -f "${REGIO_BANK_EXPLORER_USER_FILE}" ]; then
- EXP_USER=$(tr -d '\n\r' <"${REGIO_BANK_EXPLORER_USER_FILE}")
- elif [ -n "${TALER_DEPLOYMENT_SECRETS:-}" ] && [ -f "${TALER_DEPLOYMENT_SECRETS}/regio/bank-explorer-user.txt" ]; then
- EXP_USER=$(tr -d '\n\r' <"${TALER_DEPLOYMENT_SECRETS}/regio/bank-explorer-user.txt")
- else
- EXP_USER="${EXP_USER:-lena.v92}"
- [ "$EXP_USER" = "explorer" ] && EXP_USER=lena.v92
- fi
- export EXP_USER
- if [ -n "${REGIO_BANK_EXPLORER_SECRET:-}" ] && [ -f "${REGIO_BANK_EXPLORER_SECRET}" ]; then
- tr -d '\n\r' <"${REGIO_BANK_EXPLORER_SECRET}"
- return 0
- fi
- if [ -n "${TALER_DEPLOYMENT_SECRETS:-}" ] && [ -f "${TALER_DEPLOYMENT_SECRETS}/regio/bank-explorer-password.txt" ]; then
- tr -d '\n\r' <"${TALER_DEPLOYMENT_SECRETS}/regio/bank-explorer-password.txt"
- return 0
- fi
- fi
-
# Stage TESTPAYSAN — never use GOA koopa-admin-secrets explorer pw first
if [ "${CUR}" = "TESTPAYSAN" ] \
|| [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \
@@ -569,18 +532,6 @@ metrics_report_load "$LOAD_BEFORE" "ladder-start" || true
# Fresh wallet per rung — baseline empty (or last-rung DB if re-used later)
metrics_report_coins "ladder-start" || true
-# EXP_USER must be set in this shell (resolve_explorer_pw runs in $(…) subshell)
-if [ "${CUR:-${EXPECT_CURRENCY:-}}" = "FLODOS" ] \
- || [[ "${TALER_DOMAIN:-}" == *regio.taler.net* ]] || [ "${TALER_DOMAIN:-}" = "regio" ]; then
- if [ -n "${REGIO_BANK_EXPLORER_USER_FILE:-}" ] && [ -f "${REGIO_BANK_EXPLORER_USER_FILE}" ]; then
- EXP_USER=$(tr -d '\n\r' <"${REGIO_BANK_EXPLORER_USER_FILE}")
- elif [ -n "${TALER_DEPLOYMENT_SECRETS:-}" ] && [ -f "${TALER_DEPLOYMENT_SECRETS}/regio/bank-explorer-user.txt" ]; then
- EXP_USER=$(tr -d '\n\r' <"${TALER_DEPLOYMENT_SECRETS}/regio/bank-explorer-user.txt")
- elif [ -z "${EXP_USER:-}" ] || [ "${EXP_USER}" = "explorer" ]; then
- EXP_USER=lena.v92
- fi
- export EXP_USER
-fi
if ! EXP_PW=$(resolve_explorer_pw); then
err bank "explorer password missing" \
"set EXP_PW / EXP_PW_FILE or SECRETS_ROOT=…/koopa/host-root (taler-bank/bank-explorer-password.txt)${SECRETS_ROOT:+ · SECRETS_ROOT=${SECRETS_ROOT}}"
@@ -588,9 +539,9 @@ if ! EXP_PW=$(resolve_explorer_pw); then
exit 1
fi
if [ -n "${SECRETS_ROOT:-}" ]; then
- info "explorer secret" "resolved user=${EXP_USER} (SECRETS_ROOT=${SECRETS_ROOT})"
+ info "explorer secret" "resolved (SECRETS_ROOT=${SECRETS_ROOT} · stage uses stagepaysan secret first when CUR=TESTPAYSAN)"
else
- info "explorer secret" "resolved user=${EXP_USER} via EXP_PW / file / stage SSH / koopa"
+ info "explorer secret" "resolved via EXP_PW / EXP_PW_FILE / stage SSH / koopa"
fi
if [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then
ok "wallet-cli" "$CLI_JS"
@@ -605,66 +556,20 @@ if ! wcli balance >"$SCRATCH/wcli-smoke.out" 2>&1; then
exit 1
fi
-# --- personal account (intro auto-account, else open registration) ---
+# --- auto-account ---
t0=$(now_ms)
-_acct_ok=0
-if curl -sS -m 30 -o "$SCRATCH/auto-account.json" "${BANK}/intro/auto-account.json" \
- && python3 -c 'import json; d=json.load(open("'"$SCRATCH"'/auto-account.json")); assert d.get("ok") or d.get("username")' 2>/dev/null; then
- _acct_ok=1
-elif [ "${CUR:-}" = "FLODOS" ] || [[ "${TALER_DOMAIN:-}" == *regio.taler.net* ]]; then
- # regio has no /intro landings — register a discreet one-shot user (disposable mail list)
- _mail_file="${REGIO_DISPOSABLE_EMAILS:-${TALER_DEPLOYMENT_SECRETS:+$TALER_DEPLOYMENT_SECRETS/regio/disposable-emails.txt}}"
- _email=""
- if [ -n "${_mail_file:-}" ] && [ -f "$_mail_file" ]; then
- _email=$(awk -F'\t' '$2=="UNUSED" && $1 !~ /^#/ {print $1; exit}' "$_mail_file")
- fi
- : "${_email:=sofia.keller4@grr.la}"
- _user=$(printf '%s' "$_email" | python3 -c 'import sys; e=sys.stdin.read().strip().split("@")[0]; print((e.replace(".","")[:10])+str(abs(hash(e))%100))')
- _pw=$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)
- _name=$(printf '%s' "$_email" | python3 -c 'import sys; e=sys.stdin.read().strip().split("@")[0].replace("."," ").title(); print(e)')
- _code=$(curl -sS -m 20 -o "$SCRATCH/auto-account.raw" -w '%{http_code}' -X POST \
- -H 'Content-Type: application/json' \
- -d "{\"username\":\"${_user}\",\"password\":\"${_pw}\",\"name\":\"${_name}\",\"email\":\"${_email}\"}" \
- "${BANK}/accounts")
- if [ "$_code" = "200" ] || [ "$_code" = "201" ]; then
- python3 - "$_user" "$_pw" "$_name" "$_email" "$SCRATCH/auto-account.json" <<'PY'
-import json,sys
-u,p,n,e,out=sys.argv[1:6]
-json.dump({"ok":True,"created_for_you":True,"username":u,"password":p,"name":n,"email":e,"balance":"FLODOS:0"}, open(out,"w"))
-PY
- if [ -n "${_mail_file:-}" ] && [ -f "$_mail_file" ]; then
- python3 - "$_mail_file" "$_email" "$_user" <<'PY'
-from pathlib import Path
-import sys
-p,email,user=Path(sys.argv[1]),sys.argv[2],sys.argv[3]
-lines=[]
-for line in p.read_text().splitlines():
- if line.startswith(email+"\t") or line.startswith(email+" "):
- parts=line.split("\t")
- while len(parts)<3: parts.append("")
- parts[1]="USED"
- parts[2]=f"personal {user}"
- line="\t".join(parts)
- lines.append(line)
-p.write_text("\n".join(lines)+"\n")
-PY
- fi
- _acct_ok=1
- info "personal account" "open-register ${_user} (no /intro; disposable mail)"
- else
- err bank "open register failed HTTP ${_code}" "$(head -c 120 "$SCRATCH/auto-account.raw" | tr '\n' ' ')"
- exit 1
- fi
+if ! curl -sS -m 30 -o "$SCRATCH/auto-account.json" "${BANK}/intro/auto-account.json"; then
+ err bank "auto-account.json unreachable"
+ exit 1
fi
ms_auto=$(elapsed_ms "$t0")
-if [ "$_acct_ok" != "1" ]; then
- err bank "auto-account create failed" "$(head -c 120 "$SCRATCH/auto-account.json" 2>/dev/null | tr '\n' ' ')"
+if ! python3 -c 'import json; d=json.load(open("'"$SCRATCH"'/auto-account.json")); assert d.get("ok") or d.get("username")' 2>/dev/null; then
+ err bank "auto-account create failed" "$(head -c 120 "$SCRATCH/auto-account.json" | tr '\n' ' ')"
exit 1
fi
ACCT_USER=$(python3 -c 'import json; print(json.load(open("'"$SCRATCH"'/auto-account.json"))["username"])')
-ok "auto-account ${ACCT_USER} (${ms_auto}ms) — personal ${CUR}:0; pool=${EXP_USER:-explorer}"
+ok "auto-account ${ACCT_USER} (${ms_auto}ms) — personal ${CUR}:0; pool=explorer"
info "auto-account password" "(see $SCRATCH/auto-account.json — not logged)"
-unset _acct_ok _mail_file _email _user _pw _name _code
# --- explorer token ---
t0=$(now_ms)
diff --git a/check_ansible_smoke.sh b/check_ansible_smoke.sh
deleted file mode 100755
index 985cf7c..0000000
--- a/check_ansible_smoke.sh
+++ /dev/null
@@ -1,182 +0,0 @@
-#!/usr/bin/env bash
-# check_ansible_smoke.sh — taler-deployment ansible / apt full-stack smoke (TASK112)
-#
-# Goals (from REF106/TASK112):
-# Container try-install against Taler apt for: trixie · trixie-testing · Ubuntu
-# PASS/FAIL artifact. Source-build path is out of scope (open-work later).
-#
-# This phase is opt-in (not in default PHASES). Prefer running on a host with
-# podman (koopa). On laptops without containers it still syntax-checks ansible
-# when TALER_DEPLOYMENT is set.
-#
-# Env:
-# TALER_DEPLOYMENT path to taler-deployment.git checkout
-# ANSIBLE_SMOKE_SKIP=1
-# ANSIBLE_SMOKE_PODMAN=1 inventory podman apt-src tracks (default 0 on non-koopa)
-# ANSIBLE_SMOKE_ARTIFACT output dir (default: $HTML_OUT or /tmp/taler-ansible-smoke)
-# ANSIBLE_SMOKE_UBUNTU_CONTAINER name (default koopa-taler-deploy-test-apt-src-ubuntu)
-# ANSIBLE_SMOKE_REQUIRE_UBUNTU=1 hard-fail when ubuntu container missing
-#
-set -euo pipefail
-ROOT=$(cd "$(dirname "$0")" && pwd)
-# shellcheck source=lib.sh
-source "$ROOT/lib.sh"
-
-if [ "${ANSIBLE_SMOKE_SKIP:-0}" = "1" ]; then
- echo "[INFO] skip phase ansible-smoke (ANSIBLE_SMOKE_SKIP=1)"
- exit 0
-fi
-
-set_area ansible-smoke
-section "ansible-smoke · taler-deployment full-stack (trixie / trixie-testing / Ubuntu)"
-
-DEPLOY="${TALER_DEPLOYMENT:-${TALER_DEPLOYMENT_ROOT:-}}"
-if [ -z "$DEPLOY" ]; then
- for c in \
- "${HOME}/src/taler-deployment" \
- "${HOME}/git/taler-deployment" \
- "${SUITE_DIR:+$SUITE_DIR/../taler-deployment}"; do
- [ -n "$c" ] && [ -d "$c/regional-currency-ng" ] && DEPLOY=$c && break
- done
-fi
-
-ART="${ANSIBLE_SMOKE_ARTIFACT:-${HTML_OUT:-/tmp}/taler-ansible-smoke}"
-mkdir -p "$ART"
-STAMP=$(date -u +%Y%m%dT%H%M%SZ)
-REPORT="$ART/report-$STAMP.txt"
-: >"$REPORT"
-log() { printf '%s\n' "$*" | tee -a "$REPORT"; }
-
-log "host=$(hostname 2>/dev/null || echo unknown) ts=$STAMP"
-log "deploy=${DEPLOY:-MISSING}"
-
-fail=0
-# On koopa, taler-deployment.git is often absent; podman track inventory still useful.
-if [ -z "$DEPLOY" ] || [ ! -d "$DEPLOY/regional-currency-ng" ]; then
- if [ "${ANSIBLE_SMOKE_PODMAN:-0}" = "1" ]; then
- info "deploy-tree" "taler-deployment missing — syntax-check skipped; continuing podman track inventory"
- log "SKIP deploy-tree (set TALER_DEPLOYMENT for syntax-check)"
- else
- err "deploy-tree" "taler-deployment regional-currency-ng not found (set TALER_DEPLOYMENT)"
- fail=1
- fi
-else
- ok "deploy-tree" "$DEPLOY/regional-currency-ng"
- RC_NG="$DEPLOY/regional-currency-ng"
- if [ -f "$RC_NG/site.yml" ]; then
- ok "site.yml" "present"
- else
- err "site.yml" "missing"
- fail=1
- fi
- if command -v ansible-playbook >/dev/null 2>&1; then
- ok "ansible-playbook" "$(ansible-playbook --version 2>/dev/null | head -1)"
- if ansible-playbook --syntax-check -i "$RC_NG/inventory.example.yml" "$RC_NG/site.yml" \
- >>"$REPORT" 2>&1; then
- ok "syntax-check" "site.yml + inventory.example.yml"
- log "PASS syntax-check"
- else
- err "syntax-check" "ansible-playbook --syntax-check failed (see $REPORT)"
- fail=1
- log "FAIL syntax-check"
- fi
- else
- info "ansible-playbook" "not installed — syntax-check skipped"
- log "SKIP syntax-check (no ansible-playbook)"
- fi
-fi
-
-# Container apt tracks: reuse aptdeploy naming; Ubuntu track is additive.
-# Heavy merchant install stays in check_apt_deploy.sh; here we inventory tracks.
-TARGETS="trixie trixie-testing ubuntu"
-log "targets=$TARGETS"
-UBUNTU_NAME="${ANSIBLE_SMOKE_UBUNTU_CONTAINER:-koopa-taler-deploy-test-apt-src-ubuntu}"
-REQUIRE_UBUNTU="${ANSIBLE_SMOKE_REQUIRE_UBUNTU:-0}"
-
-_podman_track_state() {
- # stdout: running|exited|missing
- local bin=$1 name=$2
- if "$bin" inspect -f '{{.State.Status}}' "$name" >/dev/null 2>&1; then
- "$bin" inspect -f '{{.State.Status}}' "$name" 2>/dev/null | tr -d '\r'
- else
- printf '%s\n' missing
- fi
-}
-
-if [ "${ANSIBLE_SMOKE_PODMAN:-0}" = "1" ]; then
- PODMAN_BIN="${APT_DEPLOY_PODMAN:-podman}"
- if ! command -v "$PODMAN_BIN" >/dev/null 2>&1; then
- err "podman" "ANSIBLE_SMOKE_PODMAN=1 but podman missing"
- fail=1
- else
- ok "podman" "$("$PODMAN_BIN" --version 2>/dev/null | head -1)"
- log "NOTE: full container install remains check_apt_deploy.sh (trixie tracks)."
-
- for suite in trixie trixie-testing; do
- for mode in "" "-upgrade"; do
- cname="koopa-taler-deploy-test-apt-src-${suite}${mode}"
- st=$(_podman_track_state "$PODMAN_BIN" "$cname")
- case "$st" in
- running|created)
- ok "track-$suite${mode:-}" "$cname state=$st"
- log "PASS track $cname state=$st"
- ;;
- exited|stopped)
- info "track-$suite${mode:-}" "$cname state=$st (present, not running)"
- log "WARN track $cname state=$st"
- ;;
- *)
- info "track-$suite${mode:-}" "$cname missing"
- log "MISS track $cname"
- ;;
- esac
- done
- done
-
- # Ubuntu track (additive; create image/container only with Freigabe on koopa)
- ust=$(_podman_track_state "$PODMAN_BIN" "$UBUNTU_NAME")
- case "$ust" in
- running|created)
- ok "track-ubuntu" "$UBUNTU_NAME state=$ust"
- log "PASS track $UBUNTU_NAME state=$ust"
- ;;
- exited|stopped)
- info "track-ubuntu" "$UBUNTU_NAME state=$ust (present, not running)"
- log "WARN track $UBUNTU_NAME state=$ust"
- ;;
- *)
- if [ "$REQUIRE_UBUNTU" = "1" ]; then
- err "track-ubuntu" "$UBUNTU_NAME missing (ANSIBLE_SMOKE_REQUIRE_UBUNTU=1)"
- fail=1
- log "FAIL track $UBUNTU_NAME missing"
- else
- info "track-ubuntu" "$UBUNTU_NAME missing — Freigabe needed to create ubuntu apt-src image/container"
- log "TODO ubuntu: create $UBUNTU_NAME (Freigabe); soft-miss until then"
- fi
- ;;
- esac
-
- if "$PODMAN_BIN" image exists "docker.io/library/ubuntu:24.04" 2>/dev/null \
- || "$PODMAN_BIN" image exists "ubuntu:24.04" 2>/dev/null; then
- ok "ubuntu-base-image" "ubuntu:24.04 present"
- log "PASS ubuntu-base-image"
- else
- info "ubuntu-base-image" "ubuntu:24.04 not pulled yet (optional)"
- log "MISS ubuntu-base-image"
- fi
- fi
-else
- info "podman-smoke" "skipped (set ANSIBLE_SMOKE_PODMAN=1 on koopa)"
- log "SKIP podman-smoke"
-fi
-
-if [ "$fail" -eq 0 ]; then
- log "OVERALL=PASS"
- ok "ansible-smoke" "PASS · artifact $REPORT"
-else
- log "OVERALL=FAIL"
- err "ansible-smoke" "FAIL · artifact $REPORT"
-fi
-info "artifact" "$REPORT"
-summary
-exit "$fail"
diff --git a/check_e2e.sh b/check_e2e.sh
index 31f853d..7596691 100755
--- a/check_e2e.sh
+++ b/check_e2e.sh
@@ -489,17 +489,6 @@ if [ "$E2E_REMOTE" = "1" ]; then
fi
unset _fp_try _fp_host _remote_bank_sec
fi
- # regio.taler.net (FLODOS): path from taler-deployment-secrets via env
- if [ -z "$ADMIN_PASS" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "FLODOS" ]; then
- _regio_sec="${REGIO_BANK_ADMIN_SECRET:-}"
- [ -z "$_regio_sec" ] && [ -n "${TALER_DEPLOYMENT_SECRETS:-}" ] && \
- _regio_sec="${TALER_DEPLOYMENT_SECRETS}/regio/bank-admin-password.txt"
- if [ -n "$_regio_sec" ] && [ -f "$_regio_sec" ]; then
- ADMIN_PASS=$(tr -d '\n\r' <"$_regio_sec")
- info "bank admin secret" "from $_regio_sec"
- fi
- unset _regio_sec
- fi
# Stage merchant: public templates need no token; optional default-instance token
if [ -z "$MPW" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
if [ -n "${FRANCPAYSAN_SECRETS:-}" ] && [ -f "${FRANCPAYSAN_SECRETS}/stage/default-instance-token.txt" ]; then
@@ -563,12 +552,6 @@ merchant_auth_header() {
esac
}
-# EBICS / no public bank.* — withdraw path needs x-taler-bank (libeufin)
-if [ "${CHECK_BANK:-1}" != "1" ] || [ "${TALER_BANK_BACKEND:-}" = "ebics" ]; then
- info "e2e" "skipped — backend=${TALER_BANK_BACKEND:-ebics} (no public bank.* / x-taler-bank)"
- exit 0
-fi
-
# Public reachability gates (remote: soft-skip if bank/merchant absent)
for pair in \
"bank|$BANK_PUBLIC/config" \
diff --git a/check_mail.sh b/check_mail.sh
index 7dab097..7f92433 100755
--- a/check_mail.sh
+++ b/check_mail.sh
@@ -213,44 +213,12 @@ while IFS= read -r line || [ -n "$line" ]; do
h=$(echo "$h" | tr '[:upper:]' '[:lower:]' | sed 's/\.$//;s/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$h" ] && continue
- mapfile -t _a4 < <(dns_a "$h" | sed '/^$/d')
- addrs=$(printf '%s ' "${_a4[@]}")
+ addrs=$(dns_a "$h" | tr '\n' ' ')
if [ -z "${addrs// }" ]; then
fail "dns" "$h does not resolve (A/AAAA)"
continue
fi
ok "dns" "$h → $addrs"
- # Explicit IPv6 signal
- _has_v4=0
- _has_v6=0
- for _ip in "${_a4[@]}"; do
- case "$_ip" in
- *:*) _has_v6=1 ;;
- *) _has_v4=1 ;;
- esac
- done
- if [ "$_has_v4" = "1" ] && [ "$_has_v6" = "0" ]; then
- warn "ipv6" "$h has A but no AAAA"
- elif [ "$_has_v6" = "1" ]; then
- ok "ipv6" "$h has AAAA"
- fi
- # Reverse DNS (PTR) on first IPv4
- for _ip in "${_a4[@]}"; do
- case "$_ip" in
- *:*) continue ;;
- esac
- if command -v dig >/dev/null 2>&1; then
- ptr=$(dig +short +time=3 +tries=2 -x "$_ip" 2>/dev/null | sed 's/\.$//' | tr '\n' ' ')
- else
- ptr=$(host "$_ip" 2>/dev/null | awk '/domain name pointer/{print $NF}' | sed 's/\.$//' | tr '\n' ' ')
- fi
- if [ -n "${ptr// }" ]; then
- ok "rdns" "$_ip PTR → $ptr"
- else
- warn "rdns" "$_ip has no PTR"
- fi
- break
- done
# skip full protocol re-probe if already done
if [ "${HOST_DONE[$h]:-}" = "1" ]; then
diff --git a/check_sanity.sh b/check_sanity.sh
index 9c5b645..1e3b922 100755
--- a/check_sanity.sh
+++ b/check_sanity.sh
@@ -31,9 +31,6 @@ set_area sanity
set_group bank
section "sanity · bank"
# ---------------------------------------------------------------------------
-if [ "${CHECK_BANK:-1}" != "1" ] || [ "${TALER_BANK_BACKEND:-}" = "ebics" ]; then
- info "bank" "IBAN/EBICS (CHECK_BANK=${CHECK_BANK:-0} · backend=${TALER_BANK_BACKEND:-ebics} — no public bank.*)"
-else
expect_code "bank public /config" 200 "$BANK_PUBLIC/config"
expect_code "bank public /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
expect_code "bank public /webui/" 200 "$BANK_PUBLIC/webui/"
@@ -101,7 +98,6 @@ REMOTE
else
warn "bank server-side" "ssh ${KOOPA_SSH} unavailable"
fi
-fi
# ---------------------------------------------------------------------------
set_group exchange
diff --git a/domains.conf b/domains.conf
index 6530df6..1002e69 100644
--- a/domains.conf
+++ b/domains.conf
@@ -5,13 +5,9 @@
#
# - name: what -d / TALER_DOMAIN matches (aliases allowed as extra lines)
# - bank / exchange / merchant: hostname or full https:// URL
-# bank may be "-" / ebics / none when the stack is IBAN/EBICS (no public
-# libeufin bank.*). monitord also auto-detects from exchange /keys payto://
-# (x-taler-bank → expect bank.*; iban-only → ebics_no_bank, blue).
# - currency: GOA | KUDOS | CHF | … (empty or "-" = report only)
# - local: 1 = koopa SSH/inside/e2e stack; 0 = public-only
-# - landing: 1 = custom /intro landings (GOA + FrancPaysan/TESTPAYSAN stage only).
-# 0 = no /intro (demo/test/regio/TOPS — not the custom landing stack)
+# - landing: 1 = check /intro landings + assets; 0 = skip (no GOA-style landings)
# - lang: UI language for mon console/HTML (en|fr|de). Source of truth when
# TALER_MON_LANG_SET≠1. Workstation TALER_MON_LANG=en does not override.
# **de is supported but must not be used as a profile default** (no row
@@ -25,37 +21,26 @@
# CLI: --lang en|fr|de · TALER_MON_LANG_SET=1 locks UI lang
# Alternate file: TALER_DOMAINS_CONF=/path/to/domains.conf
-# Local GOA (koopa) — merchant host taler.hacktivism.ch · landings · UI en · locale de-CH
-# monitord includes local=1 by default (stack id hacktivism.ch).
+# Local GOA (koopa) — landings matter · UI en · locale de-CH
koopa bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch GOA 1 1 en de-CH hacktivism.ch
hacktivism bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch GOA 1 1 en de-CH hacktivism.ch
hacktivism.ch bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch GOA 1 1 en de-CH
-taler.hacktivism.ch bank.hacktivism.ch exchange.hacktivism.ch taler.hacktivism.ch GOA 1 1 en de-CH hacktivism.ch
-# Public demo (KUDOS) — no custom /intro landings
-taler.net bank.demo.taler.net exchange.demo.taler.net backend.demo.taler.net KUDOS 0 0 en de-CH demo.taler.net
-demo.taler.net bank.demo.taler.net exchange.demo.taler.net backend.demo.taler.net KUDOS 0 0 en de-CH
+# Public demo (KUDOS)
+taler.net bank.demo.taler.net exchange.demo.taler.net backend.demo.taler.net KUDOS 0 1 en de-CH demo.taler.net
+demo.taler.net bank.demo.taler.net exchange.demo.taler.net backend.demo.taler.net KUDOS 0 1 en de-CH
# Public test stack (TESTKUDOS)
test.taler.net bank.test.taler.net exchange.test.taler.net backend.test.taler.net TESTKUDOS 0 0 en de-CH
-# TOPS CHF — EBICS/IBAN wire (no public bank.*); monitord softens bank via /keys
+# TOPS CHF
taler-ops.ch bank.taler-ops.ch exchange.taler-ops.ch my.taler-ops.ch CHF 0 0 en de-CH
my.taler-ops.ch bank.taler-ops.ch exchange.taler-ops.ch my.taler-ops.ch CHF 0 0 en de-CH taler-ops.ch
stage.taler-ops.ch bank.stage.taler-ops.ch exchange.stage.taler-ops.ch stage.my.taler-ops.ch CHF 0 0 en de-CH
stage.my.taler-ops.ch bank.stage.taler-ops.ch exchange.stage.taler-ops.ch stage.my.taler-ops.ch CHF 0 0 en de-CH stage.taler-ops.ch
my.stage.taler-ops.ch bank.stage.taler-ops.ch exchange.stage.taler-ops.ch stage.my.taler-ops.ch CHF 0 0 en de-CH stage.taler-ops.ch
-# Public regional-currency demo (FLODOS) — taler-deployment / regio.taler.net
-regio.taler.net bank.regio.taler.net exchange.regio.taler.net backend.regio.taler.net FLODOS 0 0 en de-CH
-bank.regio.taler.net bank.regio.taler.net exchange.regio.taler.net backend.regio.taler.net FLODOS 0 0 en de-CH regio.taler.net
-exchange.regio.taler.net bank.regio.taler.net exchange.regio.taler.net backend.regio.taler.net FLODOS 0 0 en de-CH regio.taler.net
-backend.regio.taler.net bank.regio.taler.net exchange.regio.taler.net backend.regio.taler.net FLODOS 0 0 en de-CH regio.taler.net
-regio bank.regio.taler.net exchange.regio.taler.net backend.regio.taler.net FLODOS 0 0 en de-CH regio.taler.net
-
# Franc Paysan — UI fr · regional locale fr-CH (Suisse romande)
-# currency "-" = no Taler prod stack yet (static site only; monitord: static_pending).
-# Stage TESTPAYSAN below is the live Taler stack.
lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 fr fr-CH
monnaie.lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 fr fr-CH lefrancpaysan.ch
bank.lefrancpaysan.ch bank.lefrancpaysan.ch exchange.lefrancpaysan.ch monnaie.lefrancpaysan.ch - 0 0 fr fr-CH lefrancpaysan.ch
diff --git a/host-agent/README.md b/host-agent/README.md
index 1cbbf5b..bb438d6 100644
--- a/host-agent/README.md
+++ b/host-agent/README.md
@@ -51,14 +51,12 @@ Needs explorer secrets (`SECRETS_ROOT` / bank-explorer password). Wall ~3h (`RUN
| `koopa-taler-deploy-test-apt-src-trixie-testing` | **fresh** for `trixie-testing` |
| `koopa-taler-deploy-test-apt-src-trixie-upgrade` | **upgrade**: initial install, then mytops-style `apt install` + `upgrade` |
| `koopa-taler-deploy-test-apt-src-trixie-testing-upgrade` | **upgrade** for testing suite |
-| `koopa-taler-deploy-test-apt-src-ubuntu` | **fresh** Ubuntu 24.04 (`noble` @ deb.taler.net/apt/ubuntu) |
```bash
# from ops workstation → triggers on koopa-external
./host-agent/run-aptdeploy.sh
./host-agent/run-aptdeploy.sh upgrade
./host-agent/run-aptdeploy.sh fresh
-./host-agent/run-aptdeploy.sh ubuntu
./host-agent/run-aptdeploy.sh check-only
# on koopa
diff --git a/host-agent/ensure-apt-deploy-test-containers.sh b/host-agent/ensure-apt-deploy-test-containers.sh
index b9fa4a2..5abd9c5 100755
--- a/host-agent/ensure-apt-deploy-test-containers.sh
+++ b/host-agent/ensure-apt-deploy-test-containers.sh
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
# ensure-apt-deploy-test-containers.sh
#
-# Rootless podman containers on koopa (no nginx / no HTTPS):
+# Four rootless podman containers on koopa (no nginx / no HTTPS):
#
# Fresh rebuild when repo fingerprint changes:
# koopa-taler-deploy-test-apt-src-trixie
# koopa-taler-deploy-test-apt-src-trixie-testing
-# koopa-taler-deploy-test-apt-src-ubuntu (noble / ubuntu:24.04; mode ubuntu|all)
#
# Persistent upgrade path (mytops upgrade.sh style):
# koopa-taler-deploy-test-apt-src-trixie-upgrade
@@ -25,13 +24,12 @@ mkdir -p "$STATE_DIR"
# Packages we track for "new version → rebuild fresh containers"
TRACK_PKGS="${APT_DEPLOY_TRACK_PKGS:-taler-merchant libtalermerchant libtalerexchange libdonau}"
-# Canonical containers (keep). Everything else matching legacy names is removed.
+# Canonical four containers (keep). Everything else matching legacy names is removed.
CANONICAL_CONTAINERS=(
koopa-taler-deploy-test-apt-src-trixie
koopa-taler-deploy-test-apt-src-trixie-testing
koopa-taler-deploy-test-apt-src-trixie-upgrade
koopa-taler-deploy-test-apt-src-trixie-testing-upgrade
- koopa-taler-deploy-test-apt-src-ubuntu
)
# Legacy / typo names from earlier iterations — always delete if present
@@ -350,14 +348,7 @@ ensure_upgrade() {
upgrade_inside "$name"
}
-MODE="${1:-all}" # all | fresh | upgrade | ubuntu
-
-ensure_ubuntu_noble() {
- # Ubuntu 24.04 (noble) apt-src track — Freigabe 2026-09-05
- IMG="${APT_DEPLOY_UBUNTU_IMAGE:-docker.io/library/ubuntu:24.04}" \
- URI_BASE="${APT_DEPLOY_UBUNTU_URI:-https://deb.taler.net/apt/ubuntu}" \
- ensure_fresh koopa-taler-deploy-test-apt-src-ubuntu noble taler.sources
-}
+MODE="${1:-all}" # all | fresh | upgrade
case "$MODE" in
all|fresh)
@@ -367,12 +358,9 @@ case "$MODE" in
all|upgrade)
ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-upgrade trixie taler.sources
ensure_upgrade koopa-taler-deploy-test-apt-src-trixie-testing-upgrade trixie-testing taler-testing.sources
- ;;&
- all|ubuntu)
- ensure_ubuntu_noble
;;
*)
- echo "usage: $0 [all|fresh|upgrade|ubuntu]" >&2
+ echo "usage: $0 [all|fresh|upgrade]" >&2
exit 2
;;
esac
diff --git a/host-agent/env/mytops-stage.env.example b/host-agent/env/mytops-stage.env.example
index 548f9bc..0da4f19 100644
--- a/host-agent/env/mytops-stage.env.example
+++ b/host-agent/env/mytops-stage.env.example
@@ -27,9 +27,7 @@ TALER_MON_LANG=en
AGENT_LABEL=mytops-stage-host-agent
STATE_NAME=taler-monitoring-mytops-stage
SUITE_UPDATE_STRICT=1
-# EBICS/IBAN wire — no public bank.* (also auto-detected from exchange /keys)
CHECK_BANK=0
-CHECK_BANK_SET=1
MERCHANT_REQUIRED=1
CHECK_LANDING=0
# rusty devtesting (SSH Host taler-rusty-devtesting → devtesting@rusty.taler-ops.ch)
diff --git a/lib.sh b/lib.sh
index 42e989b..eab70c7 100755
--- a/lib.sh
+++ b/lib.sh
@@ -79,12 +79,6 @@ fi
: "${EXCHANGE_LOCAL:=http://127.0.0.1:9011}"
: "${MERCHANT_LOCAL:=https://127.0.0.1:9010}"
: "${LANDING_LOCAL:=http://127.0.0.1:9013}"
-# Public libeufin bank.* checks (urls/sanity/e2e). Auto 0 for EBICS/IBAN wire.
-: "${CHECK_BANK:=1}"
-: "${CHECK_BANK_SET:=0}"
-# x-taler-bank | ebics | unknown — filled by resolve_taler_bank_backend
-: "${TALER_BANK_BACKEND:=}"
-: "${TALER_BANK_BACKEND_SET:=0}"
# Hostnames/paths: set via ~/.config/taler-monitoring/env (no hardcoded machine values).
: "${KOOPA_SSH:=}"
: "${KOOPA_SSH_FALLBACKS:=}"
@@ -155,77 +149,16 @@ MERCHANT_PUBLIC=${MERCHANT_PUBLIC%/}
_MONITOR_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
: "${TALER_DOMAINS_CONF:=${_MONITOR_LIB_DIR}/domains.conf}"
-# host_or_url → https://… (no trailing slash). Empty for EBICS skip tokens.
+# host_or_url → https://… (no trailing slash)
_to_https_base() {
local x="${1%/}"
- case "$(printf '%s' "$x" | tr '[:upper:]' '[:lower:]')" in
- ""|"-"|"."|"none"|"n/a"|"na"|"ebics"|"iban"|"skip"|"off")
- printf ''
- ;;
+ case "$x" in
https://*|http://*) printf '%s' "$x" ;;
+ "") printf '' ;;
*) printf 'https://%s' "$x" ;;
esac
}
-# True when domains.conf bank column means "no public bank.*"
-bank_host_configured() {
- local b
- b=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')
- case "$b" in
- ""|"-"|"."|"none"|"n/a"|"na"|"ebics"|"iban"|"skip"|"off") return 1 ;;
- *) return 0 ;;
- esac
-}
-
-# Fetch exchange /keys and classify → x-taler-bank|ebics|unknown
-# (python fetch avoids SIGPIPE/pipefail on huge /keys bodies)
-detect_taler_bank_backend_from_keys() {
- local ex="${EXCHANGE_PUBLIC%/}"
- [ -n "$ex" ] || { printf 'unknown'; return 0; }
- python3 -c '
-import re, ssl, sys, urllib.error, urllib.request
-ex = sys.argv[1].rstrip("/")
-ctx = ssl.create_default_context()
-ctx.check_hostname = False
-ctx.verify_mode = ssl.CERT_NONE
-req = urllib.request.Request(
- ex + "/keys",
- headers={"User-Agent": "taler-monitoring/bank-backend"},
- method="GET",
-)
-try:
- with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
- text = resp.read(600000).decode("utf-8", "replace")
-except Exception:
- print("unknown")
- raise SystemExit(0)
-methods = {m.lower() for m in re.findall(r"payto://([a-z0-9-]+)/", text, flags=re.I)}
-if "x-taler-bank" in methods:
- print("x-taler-bank")
-elif "iban" in methods:
- print("ebics")
-else:
- print("unknown")
-' "$ex" 2>/dev/null || printf 'unknown'
-}
-
-# Set TALER_BANK_BACKEND + auto CHECK_BANK=0 for EBICS (unless CHECK_BANK_SET=1).
-resolve_taler_bank_backend() {
- local backend="${TALER_BANK_BACKEND:-}"
- if [ "${TALER_BANK_BACKEND_SET:-0}" = "1" ] && [ -n "$backend" ]; then
- :
- elif [ -z "${BANK_PUBLIC}" ]; then
- backend=ebics
- else
- backend=$(detect_taler_bank_backend_from_keys)
- fi
- TALER_BANK_BACKEND="$backend"
- if [ "$TALER_BANK_BACKEND" = "ebics" ] && [ "${CHECK_BANK_SET:-0}" != "1" ]; then
- CHECK_BANK=0
- fi
- export TALER_BANK_BACKEND CHECK_BANK CHECK_BANK_SET TALER_BANK_BACKEND_SET
-}
-
# 1 = check GOA-style /intro landing pages (assets, link crawl, demo-withdraw).
# 0 = skip (e.g. taler-ops.ch / mytops — no public landings).
: "${CHECK_LANDING:=1}"
@@ -233,19 +166,12 @@ resolve_taler_bank_backend() {
# Declare bank + exchange + merchant (+ currency, local stack, landing, domain id).
# Usage:
# set_taler_stack BANK EXCHANGE MERCHANT [CURRENCY] [LOCAL 0|1] [LANDING 0|1] [TALER_DOMAIN]
-# BANK/EXCHANGE/MERCHANT: hostname or full URL; bank may be "-" / ebics (no public bank.*)
+# BANK/EXCHANGE/MERCHANT: hostname or full URL
set_taler_stack() {
local bank="${1:-}" exchange="${2:-}" merchant="${3:-}"
local currency="${4:-}" local_stack="${5:-0}" landing="${6:-}" domain_id="${7:-}"
- if bank_host_configured "$bank"; then
- BANK_PUBLIC=$(_to_https_base "$bank")
- else
- BANK_PUBLIC=""
- if [ "${TALER_BANK_BACKEND_SET:-0}" != "1" ]; then
- TALER_BANK_BACKEND=ebics
- fi
- fi
+ BANK_PUBLIC=$(_to_https_base "$bank")
EXCHANGE_PUBLIC=$(_to_https_base "$exchange")
MERCHANT_PUBLIC=$(_to_https_base "$merchant")
# currency "-" or empty → report-only (do not force GOA)
@@ -526,11 +452,6 @@ apply_taler_domain() {
if [ "${LOCAL_STACK}" = "1" ]; then
INSIDE_PROFILE="${INSIDE_PROFILE:-koopa}"
fi
-
- # EBICS/IBAN (TOPS) → no public bank.*; x-taler-bank → keep CHECK_BANK
- if [ "${TALER_SKIP_BANK_BACKEND_DETECT:-0}" != "1" ]; then
- resolve_taler_bank_backend
- fi
}
# Apply TALER_DOMAIN from env once (CLI exports TALER_DOMAIN_APPLIED=1 after overrides).
diff --git a/mail-catalog.conf b/mail-catalog.conf
index 5cd6a00..f489234 100644
--- a/mail-catalog.conf
+++ b/mail-catalog.conf
@@ -18,4 +18,3 @@ gnunet.org firefly.gnunet.org firefly.gnunet.org
# Port 587 often closed/filtered on this plane → WARN only (see check_mail.sh).
taler-systems.com mail.anastasis.lu mail.anastasis.lu,pixel.taler-systems.com 25,465,587 143,993 tsa-anastasis
anastasis.lu mail.anastasis.lu mail.anastasis.lu 25,465,587 143,993 anastasis-lu
-taler-ops.ch pixel.taler-systems.com mail.anastasis.lu,pixel.taler-systems.com 25,465,587 143,993 tops-mail
diff --git a/ports-whitelist.conf b/ports-whitelist.conf
deleted file mode 100644
index 8bea64c..0000000
--- a/ports-whitelist.conf
+++ /dev/null
@@ -1,48 +0,0 @@
-# ports-whitelist.conf — operator override for *intentionally* open public ports
-#
-# Monitoring is outside-in only (TCP connect from the internet). Anything odd that
-# is still open and not listed here stays suspicious — many weird ports ⇒ look closer.
-# This file does not invent internal topology; it only silences known-good public opens.
-#
-# Format: host_or_pattern port[,port…] [# note]
-# Patterns: exact FQDN | *.example.com | * (any host)
-# Baseline already allows 80/443 on all hosts, and mail ports on mail-catalog hosts.
-# Override path: TALER_PORTS_WHITELIST=/path/to/file
-#
-# Whitelisted ports must NOT raise orange/red on the stack card.
-
-# --- hacktivism public edge (observed / intentional WAN services) ---
-# 22 ssh · 8080 Tor OR · 9200 Forgejo git-SSH (public; not treated as Elasticsearch here)
-*.hacktivism.ch 22,8080,9200
-hacktivism.ch 22,8080,9200
-
-# Common intentional SSH on public edges:
-*.taler.net 22
-*.taler-ops.ch 22
-*.lefrancpaysan.ch 22
-*.taler-systems.com 22
-*.gnunet.org 22
-*.anastasis.lu 22
-*.infomaniak.ch 22
-anastasis.lu 22,25,143,465,587,993
-taler-systems.com 22
-gnunet.org 22
-
-# Shared mail/edge ports often co-hosted on public FQDNs (soft noise otherwise):
-*.taler.net 25,143,465,587,993,8080
-*.test.taler.net 25,143,465,587,993,8080
-firefly.gnunet.org 22,25,143,465,587,993,9418
-pixel.taler-systems.com 22,25,143,465,587,993
-mail.anastasis.lu 22,25,143,465,587,993
-
-# Firefly git daemon (admin-log firefly/14-git-daemon.txt · ListenStream=9418)
-git.taler.net 22,9418
-git.gnunet.org 22,9418
-
-# VeciGate WAN edge (vecigate-admin-log · public A 212.51.151.254)
-# 80/443 baseline; 8080 Tor OR; 9200 Forgejo git-SSH; 2323x host SSH DNAT;
-# 1789/1790 Nym; tortuna companion 23230 + 52323 (nat README)
-212.51.151.254 22,8080,9200,1789,1790,23230,23235,23236,23237,23238,52323
-
-# Optional — add more as you confirm intentional opens:
-# lefrancpaysan.ch 21
diff --git a/secrets.env.example b/secrets.env.example
index af675df..ccf885c 100644
--- a/secrets.env.example
+++ b/secrets.env.example
@@ -26,14 +26,6 @@
# MERCHANT_TOKEN=
# MERCHANT_INSTANCE_PASSWORD=
-# --- regio.taler.net (FLODOS / taler-deployment) ---
-# Prefer path via taler-monitoring-env (no password here):
-# TALER_DEPLOYMENT_SECRETS=$HOME/src/taler-deployment-secrets
-# REGIO_BANK_ADMIN_USER=admin
-# REGIO_BANK_ADMIN_SECRET=$HOME/src/taler-deployment-secrets/regio/bank-admin-password.txt
-# One-shot override:
-# E2E_BANK_ADMIN_PASS=… # same as bank admin password
-
# --- FrancPaysan STAGE (TESTPAYSAN) e2e ---
# Default phases for -d stage.lefrancpaysan.ch: urls + inside + versions + e2e
# inside uses francpaysan-stage-user (stagepaysan); stats from public HTTPS.
diff --git a/services-catalog.conf b/services-catalog.conf
deleted file mode 100644
index 27455f6..0000000
--- a/services-catalog.conf
+++ /dev/null
@@ -1,33 +0,0 @@
-# services-catalog.conf — non-Taler company / firefly / pixel hosts (monitord)
-#
-# Format (whitespace-separated; # comments):
-# id host label zone
-#
-# zone hints: pixel | firefly | tops | public
-# Override: TALER_SERVICES_CONF=/path/to/file
-
-# --- Pixel / TSA / Anastasis ---
-taler-systems.com taler-systems.com company pixel
-www.taler-systems.com www.taler-systems.com company pixel
-erp.stage.taler-systems.com erp.stage.taler-systems.com ERPnext pixel
-anastasis.lu anastasis.lu anastasis pixel
-www.anastasis.lu www.anastasis.lu anastasis pixel
-pixel.taler-systems.com pixel.taler-systems.com pixel pixel
-
-# TOPS company web (CHF Taler stack is in domains.conf as taler-ops.ch)
-www.taler-ops.ch www.taler-ops.ch company tops
-
-# --- Firefly / GNUnet (mail plane hosts are in mail-catalog.conf) ---
-gnunet.org gnunet.org gnunet firefly
-www.gnunet.org www.gnunet.org gnunet firefly
-bugs.gnunet.org bugs.gnunet.org MantisBT firefly
-taler.net-apex taler.net taler-net firefly
-git.taler.net git.taler.net Git firefly
-git.gnunet.org git.gnunet.org Git firefly
-mattermost.taler.net mattermost.taler.net Mattermost firefly
-buildbot.gnunet.org buildbot.gnunet.org Buildbot firefly
-buildbot.taler.net buildbot.taler.net Buildbot firefly
-
-# --- VeciGate edge (+ tortuna companion via WAN DNAT) ---
-# Facts: PATH[vecigate-admin-log] docs/port-map-*.md · ip/firewall/nat/README.md
-vecigate-wan 212.51.151.254 VeciGate vecigate
diff --git a/site-gen/README.md b/site-gen/README.md
index 9657c8b..6d95bbf 100644
--- a/site-gen/README.md
+++ b/site-gen/README.md
@@ -1,97 +1,14 @@
# site-gen — HTML converter + Caddy notes
-**Product:** **LLM** = **Lightweight Monitoring** (Taler-stack watcher). CLI/tree still `taler-monitoring` / `taler-monitord`.
-
**Required for host-agent:** `console_to_html.py` (log → sticky HTML).
| File | Role |
|------|------|
| `console_to_html.py` | Convert suite console log → public mon HTML |
-| `stack_status_lib.py` | Domain-stack traffic lights + aggregation |
-| `taler-monitord` | Daemon: probe stacks → `stack-status.json` (UI detached) |
-| `stack_status_html.py` | Render status cards from JSON only |
| `caddy-monitoring-handles.snippet` | Caddy `handle` / bare redir notes |
| `ROOT-ON-KOOPA.md` | Publish staging → `/var/www` (paths from env) |
| `settings.conf.example` | Optional settings for **legacy** multi-host generator |
-## Stack status (taler-monitord)
-
-Like `llm-controld`: the daemon collects; the page only reads JSON.
-
-```bash
-./site-gen/taler-monitord --once
-./site-gen/stack_status_html.py --out /tmp/taler-stack-status.html
-# optional: ./site-gen/taler-monitord --interval 20
-# local GOA (hacktivism.ch / taler.hacktivism.ch) is included by default;
-# skip with --no-include-local or TALER_MONITORD_INCLUDE_LOCAL=0
-```
-
-Lights: green ok · blue present · yellow soft · orange hard/recent · red acute · black long · white gone.
-Box colour = weakest member; **muted** if partial; **full** if all members red/black.
-
-**Total score** (header): `N/M green` across stack+service+mail cards. Ratio thresholds (env override):
-1.0 green · ≥0.85 blue · ≥0.60 yellow · ≥0.35 orange · ≥0.01 red · 0 white (gone) / all-black → black.
-
-Stacks with currency `-` in `domains.conf` are **`static_pending`** (e.g. lefrancpaysan.ch: static site, no Taler prod yet). monitord probes the apex as `site` and treats missing bank/exchange/merchant as **blue `no_prod_yet`**, not orange/red. Stage TESTPAYSAN remains a normal Taler stack.
-
-**Bank backend:** exchange `/keys` `payto://` methods decide whether `bank.*` is expected.
-`payto://x-taler-bank/…` → probe bank (libeufin).
-`payto://iban/…` only (EBICS / TOPS CHF) → bank member **blue**, host **`IBAN`**, reason `ebics_iban` (not red).
-Mixed (e.g. regio) still probes bank. Override in `domains.conf`: bank column `-` / `ebics` / `none`.
-
-**Network / SSH:** each stack gets a coarse **zone** (GOA/local, FP stage, TOPS, pixel, firefly, public demo).
-SSH = **TCP:22 banner** (service present), not Magikoopa jump login.
-Zone-mapped SSH targets come **only** from `~/.config/taler-monitoring/env` (`TALER_MONITORD_SSH_TOPS` / `_FP` / `_GOA` / `_TOPS_PROD` via **taler-monitoring-env**); hostnames are **hidden** in the UI when set. Without env, probes use public stack FQDNs. Disable: `TALER_MONITORD_SSH=0`.
-
-**Outside CVEs:** `Server` headers (nginx/apache/caddy + version) → OSV.
-**CRITICAL → red**, **HIGH → orange**. UI: CVE tags **link to osv.dev**; card click opens detail (severity / version / summary). Logos for nginx/apache/caddy. Disable: `TALER_MONITORD_CVE=0`.
-
-**Why Degraded:** each card shows a one-line aggregation (`4× OK, 3× Soft → Degraded`); click opens a dialog with per-member reasons.
-
-**Company / services:** `services-catalog.conf` (ERPnext, MantisBT, company apexes, …). Disable: `TALER_MONITORD_SERVICES=0`.
-
-**Mail:** `mail-catalog.conf` → MX / SPF / DMARC / A+AAAA / rDNS + SMTP/IMAP TCP ports (587 soft).
-**Deliverability** (regular, not every cycle): SMTP `MAIL FROM`/`RCPT TO` smoke to `postmaster@domain` (no DATA).
-Cached under monitord state (`mail-deliverability.json`); default interval **6h**, stale after **30h** → yellow.
-Send/server failures → orange/red (degraded). Env: `TALER_MONITORD_DELIVER=0`, `_INTERVAL_S`, `_STALE_S`, `_FROM`, `_RCPT`.
-Disable plane: `TALER_MONITORD_MAIL=0`; ports only: `TALER_MONITORD_MAIL_PORTS=0`.
-
-**TLS certs + orphans:** cert days-left + SANs. Orphans = **DNS-missing** SANs not covered by monitored hosts; wildcards/siblings skipped.
-HTTP orphans opt-in: `TALER_MONITORD_ORPHAN_HTTP=1`. Disable: `TALER_MONITORD_CERT=0` / `TALER_MONITORD_ORPHAN=0`.
-
-**Port scan (outside-in only):** TCP connect to notable ports on publicly named hosts. No internal firewall maps required.
-Baseline allows 80/443 (+ mail ports on mail-catalog hosts). Anything else open → **suspicious** until listed in `ports-whitelist.conf`.
-Many odd open ports ⇒ worth investigating. Whitelist only silences *known intentional public* opens (e.g. hacktivism **8080** Tor OR, **9200** git-SSH) so they do not turn cards orange.
-Only **hard** findings (DB/RDP/SMB/…) raise card colour; soft stays informational. Disable: `TALER_MONITORD_PORTSCAN=0`.
-
-**Software versions:** per card from Taler `/config` (`build_version`), `Server:` (nginx/apache/caddy), SMTP banner (Postfix).
-Compared to **published** packages on `deb.taler.net` (`TALER_MONITORD_APT_SUITE`, default trixie): current / behind / ahead; chips show `installed → published` when behind.
-**Apps / SPAs:** page section with published WebUI + wallet-cli (+ GNU FTP wallet tarball when listed). Disable: `TALER_MONITORD_PUBLISHED=0`.
-
-**Publish:** Cyon **`hacktivism`**. Defaults (both):
-`…/public_html/stacks/index.html` → **https://hacktivism.ch/stacks/**
-`…/public_html/monitoring.hacktivism.ch/index.html` (addon when DNS ready)
-Override: `TALER_MONITORD_PUBLISH_PATH` or `TALER_MONITORD_PUBLISH_PATHS=a,b`. Off: `TALER_MONITORD_PUBLISH=0`.
-
-**Result index (optional):** bottom of the stacks page lists every probe grouped by traffic-light colour.
-`TALER_MONITORD_RESULT_INDEX=1` (or `colors`) · with concrete command: `=cmd` / `full`, or `=1` plus `TALER_MONITORD_RESULT_INDEX_CMD=1`. Off by default (`=0`).
-
-**Git soft probe:** Git-labelled services run infrequent `git ls-remote` over HTTPS + SSH (cached ~6h). Soft yellow on fail. Disable: `TALER_MONITORD_GIT=0`.
-
-**VeciGate:** `services-catalog.conf` zone `vecigate` (WAN IP) + intentional DNAT ports in `ports-whitelist.conf` (incl. tortuna companion).
-
-**Payability:** `TALER_MONITORD_PAY=1` shows CHF:0 / zero-pay status on matching stacks. Live wallet run only with `TALER_MONITORD_PAY_RUN=1` (interval `TALER_MONITORD_PAY_INTERVAL_S`, default 4h). Domains: `TALER_MONITORD_PAY_DOMAINS`.
-
-**Ansible smoke (opt-in phase):** `./taler-monitoring.sh ansible-smoke` → `check_ansible_smoke.sh` against `TALER_DEPLOYMENT` (`regional-currency-ng/site.yml`). Podman tracks: `ANSIBLE_SMOKE_PODMAN=1` on koopa (else reuse `aptdeploy`).
-
-**Local VCS mirror:** `~/git/www-hacktivism-wiki` rsyncs Cyon → git every **4h**
-(`ch.hacktivism.www-wiki-sync`). Tracks `stacks/` (+ optional addon). No push by default.
-
-**Custom /intro:** only **GOA** (hacktivism) and **FrancPaysan / TESTPAYSAN stage**.
-**Landing stats:** on intro stacks, `…/intro/stats.json` → compact tag.
-**Currency / coins:** from exchange `/config` `currency_specification`.
-**Endpoints:** suite-like paths; soft ≤ yellow. **`/terms` + `/privacy`:** medium → orange **only TOPS prod**.
-
## Optional / moved
Legacy multi-host generate/deploy and firecuda launchd tooling live under
diff --git a/site-gen/stack_status_html.py b/site-gen/stack_status_html.py
deleted file mode 100755
index 49518e5..0000000
--- a/site-gen/stack_status_html.py
+++ /dev/null
@@ -1,1050 +0,0 @@
-#!/usr/bin/env python3
-"""Render detached status HTML from stack-status.json (no probing)."""
-from __future__ import annotations
-
-import argparse
-import html
-import json
-import os
-import sys
-import time
-from pathlib import Path
-
-HERE = Path(__file__).resolve().parent
-sys.path.insert(0, str(HERE))
-import stack_status_lib as L # noqa: E402
-
-# Human labels instead of colour names (llm-control style)
-_STATUS_LABEL = {
- "green": "OK",
- "blue": "Present",
- "yellow": "Soft",
- "orange": "Degraded",
- "red": "Down",
- "black": "Long down",
- "white": "Gone",
-}
-
-def _logo_svg(pkg: str, *, fill: str, text: str, size: str = "16") -> str:
- return (
- f''
- f' '
- f'{_esc(text)} '
- )
-
-
-_LOGO_SVG = {
- "nginx": lambda: _logo_svg("nginx", fill="#009639", text="ngx", size="18"),
- "apache": lambda: _logo_svg("apache", fill="#d22128", text="httpd", size="14"),
- "caddy": lambda: _logo_svg("caddy", fill="#1f9ceb", text="caddy", size="14"),
- "postfix": lambda: _logo_svg("postfix", fill="#f0a000", text="pfx", size="16"),
- "mta": lambda: _logo_svg("mta", fill="#c08a00", text="mta", size="16"),
- "taler": lambda: _logo_svg("taler", fill="#5a32a3", text="T", size="22"),
- "taler-exchange": lambda: _logo_svg("taler-exchange", fill="#5a32a3", text="ex", size="16"),
- "taler-merchant": lambda: _logo_svg("taler-merchant", fill="#7b4cc7", text="mer", size="14"),
- "taler-bank": lambda: _logo_svg("taler-bank", fill="#3d2a80", text="bnk", size="14"),
- "webui": lambda: _logo_svg("webui", fill="#0d9488", text="ui", size="16"),
- "wallet": lambda: _logo_svg("wallet", fill="#2563eb", text="wal", size="14"),
-}
-
-CSS = """
-:root {
- --bg: #0f1419; --fg: #e7ecf1; --muted: #8b98a5; --card: #1a222c; --line: #243040;
- --dot-green: #3ddc84; --dot-blue: #61afef; --dot-yellow: #e6c07b;
- --dot-orange: #ff9f43; --dot-red: #ff5c5c; --dot-black: #2a2a2a; --ring-black: #9aa0a6;
-}
-* { box-sizing: border-box; }
-body {
- margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
- background: var(--bg); color: var(--fg); line-height: 1.3;
-}
-header {
- padding: .7rem 1rem; border-bottom: 1px solid var(--line);
- display: flex; flex-wrap: wrap; gap: .4rem 1rem; align-items: center;
-}
-header h1 { font-size: 1rem; margin: 0; font-weight: 650; }
-header .meta { color: var(--muted); font-size: .8rem; }
-header .score {
- display: inline-flex; align-items: center; gap: .4rem;
- font-size: .85rem; font-weight: 650;
- border: 1px solid #314050; border-radius: 999px;
- padding: .2rem .65rem; background: rgba(0,0,0,.2);
-}
-header .score .dot { width: .65rem; height: .65rem; }
-.dot.white { background: #f5f7fa; box-shadow: 0 0 0 1px #9aa0a6; }
-.score.light-green { border-color: rgba(61,220,132,.55); }
-.score.light-blue { border-color: rgba(97,175,239,.55); }
-.score.light-yellow { border-color: rgba(230,192,123,.55); }
-.score.light-orange { border-color: rgba(255,159,67,.55); }
-.score.light-red { border-color: rgba(255,92,92,.55); }
-.score.light-black { border-color: rgba(154,160,166,.45); }
-.score.light-white { border-color: rgba(245,247,250,.45); color: #c5ccd4; }
-h3.sec {
- margin: 1rem 1rem .35rem; font-size: .75rem; color: var(--muted);
- text-transform: uppercase; letter-spacing: .04em; font-weight: 600;
-}
-.grid {
- display: grid; gap: .7rem; padding: .55rem 1rem .85rem;
- grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-}
-.card {
- background: var(--card); border-radius: 10px; padding: .7rem .8rem;
- border: 2px solid #314050; min-width: 0; cursor: pointer;
-}
-.card:focus-visible { outline: 2px solid var(--dot-blue); outline-offset: 2px; }
-.card h2 { margin: 0; font-size: .9rem; font-weight: 650; overflow-wrap: anywhere; }
-.card .sub {
- color: var(--muted); font-size: .72rem; margin: .15rem 0 .45rem;
- display: flex; flex-wrap: wrap; gap: .25rem .55rem; align-items: center;
-}
-.card .sub .bad { color: var(--dot-orange); }
-.card .sub .crit { color: var(--dot-red); }
-.card .sub .ok { color: var(--dot-green); }
-.card .why {
- font-size: .68rem; color: var(--muted); margin: 0 0 .35rem;
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-}
-.rows { display: flex; flex-direction: column; gap: .15rem; }
-.row {
- display: flex; align-items: center; gap: .4rem;
- font-size: .78rem; min-width: 0;
-}
-.row .role {
- color: var(--muted); width: 3.6rem; flex: 0 0 auto;
- font-size: .65rem; text-transform: uppercase;
-}
-.row .host {
- flex: 1 1 auto; min-width: 0;
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-}
-.dot {
- width: .55rem; height: .55rem; border-radius: 50%; flex: 0 0 auto;
- box-shadow: 0 0 0 1px rgba(255,255,255,.1);
-}
-.dot.green { background: var(--dot-green); }
-.dot.blue { background: var(--dot-blue); }
-.dot.yellow { background: var(--dot-yellow); }
-.dot.orange { background: var(--dot-orange); }
-.dot.red { background: var(--dot-red); }
-.dot.black { background: var(--dot-black); box-shadow: 0 0 0 1px var(--ring-black); }
-.tags {
- display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem;
- padding-top: .45rem; border-top: 1px solid var(--line);
- align-items: center;
-}
-.tag {
- font-size: .62rem; color: var(--muted);
- border: 1px solid #314050; border-radius: 999px;
- padding: .1rem .45rem; background: rgba(0,0,0,.15);
- text-decoration: none;
-}
-a.tag:hover { filter: brightness(1.15); }
-.tag.warn { color: var(--dot-orange); border-color: rgba(255,159,67,.45); }
-.tag.crit { color: var(--dot-red); border-color: rgba(255,92,92,.45); }
-.logo { width: 1.05rem; height: 1.05rem; vertical-align: middle; flex: 0 0 auto; }
-.swrow {
- display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .45rem;
-}
-.sw {
- display: inline-flex; align-items: center; gap: .3rem;
- font-size: .62rem; border-radius: 8px; padding: .15rem .4rem .15rem .25rem;
- border: 1px solid #314050; background: rgba(0,0,0,.18); color: var(--muted);
- max-width: 100%;
-}
-.sw .ver { color: var(--fg); font-variant-numeric: tabular-nums; white-space: nowrap; }
-.sw.light-green { border-color: rgba(61,220,132,.55); }
-.sw.light-blue { border-color: rgba(97,175,239,.5); }
-.sw.light-yellow { border-color: rgba(230,192,123,.55); color: var(--dot-yellow); }
-.sw.light-orange { border-color: rgba(255,159,67,.6); color: var(--dot-orange); }
-.sw.light-red { border-color: rgba(255,92,92,.65); color: var(--dot-red); }
-.card.ok-green { border-color: rgba(61,220,132,.5); }
-.card.ok-blue { border-color: rgba(97,175,239,.5); }
-.card.muted-green { border-color: rgba(61,220,132,.3); }
-.card.muted-blue { border-color: rgba(97,175,239,.3); }
-.card.muted-yellow { border-color: rgba(230,192,123,.4); }
-.card.muted-orange { border-color: rgba(255,159,67,.45); }
-.card.muted-red { border-color: rgba(255,92,92,.4); }
-.card.muted-black { border-color: rgba(154,160,166,.35); }
-.card.full-red { border-color: var(--dot-red); background: #3a1414; }
-.card.full-black { border-color: var(--ring-black); background: #0a0a0a; }
-.card.full-orange { border-color: var(--dot-orange); background: #3a2210; }
-.stale { color: var(--dot-orange); }
-footer { padding: .7rem 1rem 1.2rem; color: var(--muted); font-size: .7rem; }
-.result-index {
- margin: .4rem 1rem 1.2rem; padding: .75rem .9rem;
- border: 1px solid var(--line); border-radius: 10px; background: #121820;
-}
-.result-index h3 {
- margin: 0 0 .55rem; font-size: .78rem; color: var(--muted);
- text-transform: uppercase; letter-spacing: .04em; font-weight: 600;
-}
-.result-index .ri-group { margin: 0 0 .65rem; }
-.result-index .ri-group:last-child { margin-bottom: 0; }
-.result-index .ri-hd {
- display: flex; align-items: center; gap: .4rem;
- font-size: .78rem; font-weight: 600; margin: 0 0 .3rem;
-}
-.result-index ul {
- margin: 0; padding-left: 1.05rem; font-size: .72rem; color: var(--muted);
-}
-.result-index li { margin: .18rem 0; }
-.result-index .ri-stack { color: var(--fg); font-weight: 500; }
-.result-index .ri-cmd {
- display: inline-block; margin-left: .35rem; padding: .05rem .35rem;
- border-radius: 4px; border: 1px solid #314050; background: rgba(0,0,0,.25);
- color: var(--dot-blue); font-family: ui-monospace, Menlo, Consolas, monospace;
- font-size: .68rem;
-}
-dialog {
- border: 1px solid var(--line); border-radius: 12px; background: #121820; color: var(--fg);
- padding: 0; max-width: min(520px, 94vw); width: 100%;
-}
-dialog::backdrop { background: rgba(0,0,0,.55); }
-.dlg-hd {
- display: flex; justify-content: space-between; gap: .5rem; align-items: start;
- padding: .85rem 1rem; border-bottom: 1px solid var(--line);
-}
-.dlg-hd h2 { margin: 0; font-size: 1rem; }
-.dlg-hd button {
- background: transparent; border: 1px solid #314050; color: var(--fg);
- border-radius: 8px; padding: .2rem .55rem; cursor: pointer;
-}
-.dlg-bd { padding: .85rem 1rem 1.1rem; font-size: .82rem; }
-.dlg-bd .agg { margin: 0 0 .7rem; color: var(--muted); }
-.dlg-bd ul { margin: 0; padding-left: 1.1rem; }
-.dlg-bd li { margin: .25rem 0; }
-.dlg-bd .cve {
- margin-top: .8rem; padding-top: .7rem; border-top: 1px solid var(--line);
-}
-.dlg-bd a { color: var(--dot-blue); }
-"""
-
-
-def _esc(s: object) -> str:
- return html.escape(str(s if s is not None else ""), quote=True)
-
-
-def _label(light: object) -> str:
- return _STATUS_LABEL.get(str(light or ""), str(light or "?"))
-
-
-def _short_host(host: str) -> str:
- h = host or ""
- if h.upper() == "IBAN":
- return "IBAN"
- return h
-
-
-def _osv_url(cve_id: str) -> str:
- cid = (cve_id or "").strip()
- if not cid:
- return "https://osv.dev/"
- return f"https://osv.dev/vulnerability/{cid}"
-
-
-def _probe_command_for_endpoint(host: str, ep: dict) -> str:
- """Concrete outside-in probe (HTTP GET) for one endpoint row."""
- if ep.get("skipped"):
- path = ep.get("path") or ep.get("id") or "?"
- return f"(skip) GET https://{host}{path}"
- url = str(ep.get("url") or "").strip()
- if not url:
- path = ep.get("path") or ""
- url = f"https://{host}{path}" if host and path else path or "?"
- return f"GET {url}"
-
-
-def _probe_command_for_member(m: dict) -> str:
- role = str(m.get("role") or "member")
- host = str(m.get("host") or "")
- reason = str(m.get("reason") or "")
- if role == "ssh" or reason.startswith("SSH-") or reason in ("ssh_probe", "timeout", "no_host"):
- target = host or "?"
- return f"TCP {target}:22 banner"
- if host.upper() == "IBAN" or reason in ("ebics_iban", "ebics_no_bank"):
- return f"(no HTTP) {role} · {reason or 'ebics'}"
- if host:
- return f"GET https://{host}/config"
- return f"probe {role}"
-
-
-def collect_result_index_rows(doc: dict) -> list[dict]:
- """Flatten probe/test rows across stacks/services/mail for the bottom index."""
- rows: list[dict] = []
- for section, key in (
- ("stack", "stacks"),
- ("service", "services"),
- ("mail", "mail"),
- ):
- for st in doc.get(key) or []:
- if not isinstance(st, dict):
- continue
- sid = str(st.get("id") or "?")
- for m in st.get("members") or []:
- if not isinstance(m, dict):
- continue
- role = str(m.get("role") or "")
- if role == "cve":
- continue
- host = str(m.get("host") or "")
- endpoints = [e for e in (m.get("endpoints") or []) if isinstance(e, dict)]
- if endpoints:
- for ep in endpoints:
- light = str(ep.get("light") or m.get("light") or "black")
- eid = str(ep.get("id") or ep.get("path") or "ep")
- reason = str(ep.get("reason") or "")
- label = f"{role}/{eid}"
- rows.append(
- {
- "section": section,
- "stack": sid,
- "role": role,
- "host": host,
- "id": eid,
- "label": label,
- "light": light,
- "reason": reason,
- "command": _probe_command_for_endpoint(host, ep),
- }
- )
- else:
- light = str(m.get("light") or "black")
- rows.append(
- {
- "section": section,
- "stack": sid,
- "role": role,
- "host": host,
- "id": role or "member",
- "label": role or "member",
- "light": light,
- "reason": str(m.get("reason") or ""),
- "command": _probe_command_for_member(m),
- }
- )
- net = st.get("network") if isinstance(st.get("network"), dict) else {}
- ssh = net.get("ssh") if isinstance(net.get("ssh"), dict) else {}
- if ssh.get("expected") and ssh.get("ok") is not None:
- shown = str(ssh.get("host") or "")
- # Prefer real candidates when not hidden; else keep blank host in cmd
- hosts = list(ssh.get("candidates") or [])
- cmd_host = shown or (hosts[0] if hosts else "")
- rows.append(
- {
- "section": section,
- "stack": sid,
- "role": "ssh",
- "host": shown,
- "id": "ssh",
- "label": "ssh",
- "light": str(ssh.get("light") or ("green" if ssh.get("ok") else "orange")),
- "reason": str(ssh.get("reason") or ""),
- "command": f"TCP {cmd_host or '∅'}:22 banner",
- }
- )
- return rows
-
-
-def _render_result_index(doc: dict, *, mode: str | None = None) -> str:
- """Optional bottom section: all tests grouped by light colour (+ optional command)."""
- mode = (mode or getattr(L, "RESULT_INDEX_MODE", "off") or "off").lower()
- if mode in ("0", "off", "false", "no", ""):
- return ""
- show_cmd = mode in ("cmd", "full", "commands", "2")
- rows = collect_result_index_rows(doc)
- if not rows:
- return ""
- order = ("green", "blue", "yellow", "orange", "red", "black", "white")
- by: dict[str, list[dict]] = {}
- for r in rows:
- by.setdefault(str(r.get("light") or "black"), []).append(r)
- groups = []
- for light in order:
- items = by.get(light) or []
- if not items:
- continue
- lis = []
- for r in items:
- tip = r.get("reason") or _label(light)
- cmd = ""
- if show_cmd and r.get("command"):
- cmd = f' {_esc(r["command"])}'
- host = r.get("host") or ""
- host_bit = f" @ {_esc(host)}" if host else ""
- lis.append(
- "
"
- f'{_esc(r.get("stack"))} · '
- f'{_esc(r.get("label"))}{host_bit}'
- f' ({_esc(_label(light))}) '
- f"{cmd}"
- " "
- )
- groups.append(
- f''
- f'
'
- f'{_esc(_label(light))} · {len(items)}
'
- f'
'
- )
- title = "all probes by colour"
- if show_cmd:
- title += " · with command"
- return (
- f''
- f"{_esc(title)} ({len(rows)}) "
- f'{"".join(groups)}'
- f" "
- )
-
-
-def _agg_text(st: dict) -> str:
- bd = st.get("breakdown") if isinstance(st.get("breakdown"), dict) else {}
- by = bd.get("by_light") if isinstance(bd.get("by_light"), dict) else {}
- if not by:
- # fallback from members
- by = {}
- for m in st.get("members") or []:
- if (m.get("reason") or "") in getattr(L, "_AGG_SKIP_REASONS", ()):
- continue
- Lgt = str(m.get("light") or "black")
- by[Lgt] = by.get(Lgt, 0) + 1
- if not by:
- return ""
- order = ("green", "blue", "yellow", "orange", "red", "black")
- parts = []
- for k in order:
- n = int(by.get(k) or 0)
- if n:
- parts.append(f"{n}× {_label(k)}")
- worst = st.get("light") or bd.get("worst") or ""
- if not parts:
- return ""
- return f"{', '.join(parts)} → {_label(worst)}"
-
-
-def _software_items(st: dict) -> list[dict]:
- raw = st.get("software") if isinstance(st.get("software"), list) else []
- if raw:
- return [x for x in raw if isinstance(x, dict) and x.get("package")]
- # Fallback: vulns.servers only
- vulns = st.get("vulns") if isinstance(st.get("vulns"), dict) else {}
- out = []
- for srv in vulns.get("servers") or []:
- p = str((srv or {}).get("package") or "")
- if not p:
- continue
- out.append(
- {
- "package": p,
- "version": (srv or {}).get("version") or "",
- "host": (srv or {}).get("host") or "",
- "light": "green",
- }
- )
- return out
-
-
-def _render_software_row(st: dict) -> str:
- items = _software_items(st)
- if not items:
- return ""
- # Collapse identical package+version (multi-host) into one chip.
- merged: dict[str, dict] = {}
- for it in items:
- pkg = str(it.get("package") or "")
- ver = str(it.get("version") or "")
- key = f"{pkg}|{ver}"
- if key not in merged:
- merged[key] = {
- "package": pkg,
- "version": ver,
- "label": it.get("label") or pkg,
- "light": it.get("light") or "green",
- "hosts": [],
- "source": it.get("source") or "",
- "currency_status": it.get("currency_status") or "",
- "behind": it.get("behind") or "",
- "published_upstream": it.get("published_upstream") or "",
- "delta": it.get("delta"),
- }
- h = str(it.get("host") or "")
- if h and h not in merged[key]["hosts"]:
- merged[key]["hosts"].append(h)
- # keep worst light
- Lgt = str(it.get("light") or "green")
- cur = str(merged[key]["light"] or "green")
- if L.SEVERITY.get(Lgt, 0) > L.SEVERITY.get(cur, 0):
- merged[key]["light"] = Lgt
- if it.get("currency_status") == "behind":
- merged[key]["currency_status"] = "behind"
- merged[key]["behind"] = it.get("behind") or merged[key].get("behind")
- merged[key]["published_upstream"] = (
- it.get("published_upstream") or merged[key].get("published_upstream")
- )
- if it.get("delta") is not None:
- merged[key]["delta"] = it.get("delta")
- chips = []
- for it in list(merged.values())[:8]:
- pkg = str(it.get("package") or "")
- ver = str(it.get("version") or "")
- light = str(it.get("light") or "green")
- hosts = it.get("hosts") or []
- label = str(it.get("label") or pkg)
- logo_fn = _LOGO_SVG.get(pkg)
- logo = logo_fn() if callable(logo_fn) else _logo_svg(pkg, fill="#445566", text=(pkg[:3] or "?"), size="14")
- cur = str(it.get("currency_status") or "")
- behind = str(it.get("behind") or it.get("published_upstream") or "")
- delta = it.get("delta")
- ver_txt = ver or "?"
- if cur == "behind" and behind:
- lag = f" −{delta}" if isinstance(delta, int) and delta > 0 else ""
- ver_txt = f"{ver_txt} → {behind}{lag}"
- elif cur == "current" and ver:
- ver_txt = f"{ver_txt} ✓"
- tip = " · ".join(
- x
- for x in (
- label,
- ver,
- f"pub {behind}" if behind else "",
- cur,
- ", ".join(hosts[:4]),
- it.get("source") or "",
- )
- if x
- )
- chips.append(
- f''
- f"{logo}"
- f'{_esc(ver_txt)} '
- f" "
- )
- return f'{"".join(chips)}
'
-
-
-def _card_dialog_payload(st: dict) -> dict:
- """JSON-serializable detail for the open dialog."""
- members = []
- for m in st.get("members") or []:
- reason = m.get("reason") or ""
- es = m.get("endpoints_summary") if isinstance(m.get("endpoints_summary"), dict) else {}
- # If endpoints pulled the light up, surface worst endpoint id/reason.
- if es and (m.get("light") or "") not in ("green", "blue", None, ""):
- bad = []
- for ep in m.get("endpoints") or []:
- if ep.get("skipped"):
- continue
- el = ep.get("light") or "green"
- if el in ("yellow", "orange", "red", "black"):
- bad.append(f"{ep.get('id') or ep.get('path')}:{ep.get('reason') or el}")
- if bad and reason in ("ok", "", None):
- reason = ", ".join(bad[:4])
- elif bad:
- reason = f"{reason} · " + ", ".join(bad[:3])
- members.append(
- {
- "role": m.get("role"),
- "host": m.get("host"),
- "light": m.get("light"),
- "label": _label(m.get("light")),
- "reason": reason,
- "code": m.get("code"),
- }
- )
- hits = []
- vulns = st.get("vulns") if isinstance(st.get("vulns"), dict) else {}
- for h in vulns.get("hits") or []:
- hits.append(
- {
- "id": h.get("id"),
- "severity": h.get("severity"),
- "package": h.get("package"),
- "version": h.get("version"),
- "host": h.get("host"),
- "summary": h.get("summary") or "",
- "url": _osv_url(str(h.get("id") or "")),
- }
- )
- tls = st.get("tls") if isinstance(st.get("tls"), dict) else {}
- ports = st.get("ports") if isinstance(st.get("ports"), dict) else {}
- software = []
- for it in _software_items(st)[:12]:
- software.append(
- {
- "package": it.get("package"),
- "label": it.get("label") or it.get("package"),
- "version": it.get("version") or "",
- "host": it.get("host") or "",
- "light": it.get("light") or "green",
- "source": it.get("source") or "",
- "currency_status": it.get("currency_status") or "",
- "published": it.get("published") or "",
- "behind": it.get("behind") or "",
- "delta": it.get("delta"),
- }
- )
- return {
- "id": st.get("id"),
- "light": st.get("light"),
- "label": _label(st.get("light")),
- "agg": _agg_text(st),
- "members": members,
- "hits": hits,
- "tls": tls,
- "ports": ports.get("suspicious") or [],
- "software": software,
- "deliverability": st.get("deliverability")
- if isinstance(st.get("deliverability"), dict)
- else {},
- "kind": st.get("kind"),
- "label_soft": st.get("service_label") or "",
- }
-
-
-def _render_card(st: dict, *, section: str = "stack") -> str:
- light = st.get("light") or "black"
- intensity = st.get("intensity") or "muted"
- klass = f"card {intensity}-{light}"
- cur = st.get("currency") or ""
- ci = st.get("currency_info") if isinstance(st.get("currency_info"), dict) else {}
- symbol = ci.get("symbol") or ""
- backend = st.get("bank_backend") or ""
- service_label = st.get("service_label") or ""
-
- bits = [x for x in (service_label, cur, symbol if symbol != cur else "", _label(light)) if x]
- if backend == "ebics":
- bits.insert(-1 if bits else 0, "IBAN")
- elif backend == "x-taler-bank":
- bits.insert(-1 if bits else 0, "x-taler-bank")
-
- net = st.get("network") if isinstance(st.get("network"), dict) else {}
- ssh = net.get("ssh") if isinstance(net.get("ssh"), dict) else {}
- sub_extra = []
- if net.get("zone"):
- sub_extra.append(_esc(str(net.get("zone"))))
- if ssh.get("expected"):
- if ssh.get("ok") is True:
- sub_extra.append('ssh ')
- elif ssh.get("ok") is False:
- sub_extra.append('ssh! ')
- vulns = st.get("vulns") if isinstance(st.get("vulns"), dict) else {}
- hits = vulns.get("hits") or []
- if hits:
- top = hits[0]
- cls = "crit" if (vulns.get("worst") or "") == "CRITICAL" else "bad"
- sub_extra.append(
- f'cve '
- )
-
- why = _agg_text(st)
- why_html = f'{_esc(why)}
' if why else ""
-
- rows = []
- for m in st.get("members") or []:
- role = m.get("role") or ""
- if role == "cve":
- continue
- host = m.get("host") or ""
- if not host and (m.get("reason") or "") in ("ebics_iban", "ebics_no_bank"):
- host = "IBAN"
- ml = m.get("light") or "black"
- tip = m.get("reason") or _label(ml)
- rows.append(
- ""
- f' '
- f'{_esc(role)} '
- f'{_esc(_short_host(host))} '
- "
"
- )
-
- tags = []
- if st.get("commercial_prod"):
- tags.append('prod ')
- if backend:
- tags.append(f'{_esc(backend)} ')
- if net.get("zone"):
- tags.append(f'{_esc(net.get("zone"))} ')
- if ssh.get("expected"):
- if ssh.get("ok") is True:
- tags.append('ssh ')
- elif ssh.get("ok") is False:
- tags.append('ssh ')
- tls = st.get("tls") if isinstance(st.get("tls"), dict) else {}
- if tls.get("days_left") is not None:
- try:
- days = int(tls["days_left"])
- except (TypeError, ValueError):
- days = None
- if days is not None:
- if days < 14:
- tags.append(f'cert {days}d ')
- elif days < 30:
- tags.append(f'cert {days}d ')
- else:
- tags.append(f'cert ')
- if st.get("orphan"):
- tags.append('orphan? ')
- deliver = st.get("deliverability") if isinstance(st.get("deliverability"), dict) else {}
- if deliver:
- dl = str(deliver.get("light") or "yellow")
- reason = str(deliver.get("reason") or "deliver")
- age = deliver.get("age_s")
- age_s = ""
- try:
- if age is not None:
- a = float(age)
- age_s = f"{int(a // 3600)}h" if a >= 3600 else f"{int(a // 60)}m"
- except (TypeError, ValueError):
- age_s = ""
- tip = " · ".join(x for x in (reason, deliver.get("rcpt") or "", age_s) if x)
- cls = "tag"
- if dl in ("orange", "red"):
- cls = "tag warn" if dl == "orange" else "tag crit"
- elif dl == "yellow" or deliver.get("stale"):
- cls = "tag warn"
- tags.append(f'deliver ')
- ports = st.get("ports") if isinstance(st.get("ports"), dict) else {}
- n_sus = int(ports.get("n_suspicious") or 0)
- if n_sus:
- tip = ", ".join(
- f"{s.get('label') or s.get('port')}"
- + (f"@{s.get('host')}" if s.get("host") else "")
- for s in (ports.get("suspicious") or [])[:8]
- )
- tags.append(
- f'ports:{n_sus} '
- )
- for h in hits[:3]:
- cid = str(h.get("id") or "cve")
- cls = "tag crit" if (h.get("severity") or "") == "CRITICAL" else "tag warn"
- tip = " · ".join(
- x
- for x in (
- h.get("severity") or "",
- f"{h.get('package')}/{h.get('version')}" if h.get("package") else "",
- (h.get("summary") or "")[:80],
- )
- if x
- )
- tags.append(
- f'{_esc(cid)} '
- )
- stats = st.get("stats") if isinstance(st.get("stats"), dict) else {}
- if stats.get("present"):
- tags.append('stats ')
- denoms = (ci.get("denoms") or [])[:6]
- if denoms:
- tags.append(
- f'denoms '
- )
-
- payload = _esc(json.dumps(_card_dialog_payload(st), ensure_ascii=False))
- title = st.get("id") or "?"
- sw_html = _render_software_row(st)
- return (
- f''
- f"{_esc(title)} "
- f'{_esc(" · ".join(bits))}'
- f'{(" · " + " ".join(sub_extra)) if sub_extra else ""}
'
- f"{why_html}"
- f'{"".join(rows)}
'
- f"{sw_html}"
- f'{"".join(tags)}
'
- " "
- )
-
-
-JS = """
-function openStack(el) {
- let raw = el.getAttribute('data-detail') || '{}';
- let d;
- try { d = JSON.parse(raw); } catch (e) { return; }
- const dlg = document.getElementById('stack-dlg');
- const title = document.getElementById('dlg-title');
- const body = document.getElementById('dlg-body');
- title.textContent = (d.id || '?') + ' · ' + (d.label || '');
- let html = '';
- if (d.agg) html += '' + escapeHtml(d.agg) + '
';
- html += '';
- for (const m of (d.members || [])) {
- if (m.role === 'cve') continue;
- const tip = m.reason || m.label || '';
- html += '' + escapeHtml(m.role || '') + ' '
- + escapeHtml(m.host || '') + ' — ' + escapeHtml(m.label || '')
- + (tip ? ' (' + escapeHtml(tip) + ') ' : '')
- + ' ';
- }
- html += ' ';
- if (d.tls && (d.tls.not_after || d.tls.days_left != null)) {
- html += 'TLS · days_left=' + escapeHtml(d.tls.days_left)
- + (d.tls.not_after ? ' · until ' + escapeHtml(d.tls.not_after) : '')
- + '
';
- if (d.tls.sans && d.tls.sans.length) {
- html += 'SANs: ' + escapeHtml(d.tls.sans.join(', ')) + '
';
- }
- }
- if (d.hits && d.hits.length) {
- html += 'CVEs ';
- for (const h of d.hits) {
- html += ''
- + escapeHtml(h.id || 'cve') + ' · ' + escapeHtml(h.severity || '')
- + ' · ' + escapeHtml((h.package || '') + '/' + (h.version || ''))
- + (h.summary ? '' + escapeHtml(h.summary) + ' ' : '')
- + ' ';
- }
- html += ' ';
- }
- if (d.software && d.software.length) {
- html += 'Software ';
- for (const s of d.software) {
- html += '' + escapeHtml(s.label || s.package)
- + (s.version ? ' · ' + escapeHtml(s.version) : '')
- + (s.currency_status === 'behind' && s.behind
- ? ' → ' + escapeHtml(s.behind) + (s.delta != null ? ' (−' + escapeHtml(s.delta) + ')' : '')
- : '')
- + (s.currency_status ? ' · ' + escapeHtml(s.currency_status) : '')
- + (s.host ? ' @ ' + escapeHtml(s.host) : '')
- + ' ';
- }
- html += ' ';
- }
- if (d.deliverability && (d.deliverability.reason || d.deliverability.light)) {
- const deliv = d.deliverability;
- html += 'Deliverability ';
- html += '' + escapeHtml(deliv.reason || '')
- + (deliv.rcpt ? ' · rcpt ' + escapeHtml(deliv.rcpt) : '')
- + (deliv.mx ? ' · mx ' + escapeHtml(deliv.mx) : '')
- + (deliv.age_s != null ? ' · age ' + escapeHtml(deliv.age_s) + 's' : '')
- + (deliv.stale ? ' · stale' : '')
- + (deliv.detail ? '' + escapeHtml(deliv.detail) : '')
- + ' ';
- }
- if (d.ports && d.ports.length) {
- html += 'Suspicious ports ';
- for (const p of d.ports) {
- html += '' + escapeHtml(p.label || p.port)
- + (p.host ? ' @ ' + escapeHtml(p.host) : '')
- + ' (whitelist in ports-whitelist.conf) ';
- }
- html += ' ';
- }
- body.innerHTML = html;
- if (typeof dlg.showModal === 'function') dlg.showModal();
- else dlg.setAttribute('open', '');
-}
-function escapeHtml(s) {
- return String(s == null ? '' : s)
- .replace(/&/g,'&').replace(//g,'>')
- .replace(/"/g,'"');
-}
-"""
-
-
-def render_html(doc: dict) -> str:
- stacks = doc.get("stacks") or []
- services = doc.get("services") or []
- mail = doc.get("mail") or []
- orphans = doc.get("orphans") or []
- ports_doc = doc.get("ports") if isinstance(doc.get("ports"), dict) else {}
- published = doc.get("published") if isinstance(doc.get("published"), dict) else {}
- ts = doc.get("ts")
- age = None
- try:
- age = time.time() - float(ts)
- except (TypeError, ValueError):
- pass
- stale = age is not None and age > 3 * float(doc.get("acute_down_s") or L.ACUTE_DOWN_S)
- if age is None:
- updated = "?"
- elif age < 120:
- updated = f"{int(age)}s"
- elif age < 3600:
- updated = f"{int(age // 60)}m"
- else:
- updated = f"{age / 3600:.1f}h"
- try:
- updated += " · " + time.strftime("%H:%M", time.localtime(float(ts)))
- except (TypeError, ValueError, OSError):
- pass
-
- stack_cards = [_render_card(st, section="stack") for st in stacks]
- # Firefly-plane (git / Mattermost / buildbot / gnunet) near mail; VeciGate edge; rest = company
- def _zone(st: dict) -> str:
- return str((st.get("network") or {}).get("zone") or st.get("zone") or "").lower()
-
- firefly_services = [st for st in services if _zone(st) == "firefly"]
- vecigate_services = [st for st in services if _zone(st) == "vecigate"]
- other_services = [
- st for st in services if _zone(st) not in ("firefly", "vecigate")
- ]
- service_cards = [_render_card(st, section="service") for st in other_services]
- firefly_cards = [_render_card(st, section="service") for st in firefly_services]
- vecigate_cards = [_render_card(st, section="service") for st in vecigate_services]
- mail_cards = [_render_card(st, section="mail") for st in mail]
-
- score = doc.get("score") if isinstance(doc.get("score"), dict) else None
- if not score:
- score = L.compute_total_score(stacks, services, mail)
- s_light = str(score.get("light") or "white")
- s_green = int(score.get("green") or 0)
- s_total = int(score.get("total") or 0)
- s_pct = int(score.get("pct") or 0)
- score_html = (
- f''
- f' '
- f'{s_green}/{s_total} green '
- f'({s_pct}%) · {_esc(_label(s_light))} '
- f"
"
- )
-
- orphan_html = ""
- if orphans:
- items = "".join(
- f'{_esc(o.get("host"))} · {_esc(o.get("reason") or "orphan")} '
- for o in orphans[:40]
- )
- orphan_html = f'orphans '
-
- ports_html = ""
- sus_all = ports_doc.get("suspicious") or []
- if ports_doc.get("enabled") and sus_all:
- n_hard = int(ports_doc.get("n_hard") or 0)
- items = "".join(
- f' '
- f'{_esc(s.get("label") or s.get("port"))}'
- f'{(" @ " + _esc(s.get("host"))) if s.get("host") else ""}'
- f' · {_esc(s.get("severity") or "soft")} '
- for s in sus_all[:60]
- )
- ports_html = (
- f'suspicious ports ({len(sus_all)}'
- f'{f", {n_hard} hard" if n_hard else ""}) · whitelist: ports-whitelist.conf '
- f''
- )
-
- sections = []
- sections.append("taler stacks ")
- empty = 'No stacks.
'
- sections.append(f'{"".join(stack_cards) if stack_cards else empty} ')
- if service_cards:
- sections.append('company / services ')
- sections.append(f'{"".join(service_cards)} ')
- if firefly_cards or mail_cards:
- sections.append('firefly plane · git / Mattermost / buildbot / mail ')
- if firefly_cards:
- sections.append(f'{"".join(firefly_cards)} ')
- if mail_cards:
- sections.append(f'{"".join(mail_cards)} ')
- if vecigate_cards:
- sections.append('VeciGate edge · WAN / tortuna companion ports ')
- sections.append(f'{"".join(vecigate_cards)} ')
- apps = published.get("apps") or []
- apps_html = ""
- if apps:
- chips = []
- for app in apps[:12]:
- if not isinstance(app, dict):
- continue
- logo_key = str(app.get("logo") or app.get("kind") or "webui")
- logo_fn = _LOGO_SVG.get(logo_key) or _LOGO_SVG.get("webui")
- logo = logo_fn() if callable(logo_fn) else _logo_svg(logo_key, fill="#0d9488", text="app", size="14")
- pub = str(app.get("published_upstream") or app.get("published") or "?")
- light = str(app.get("light") or "green")
- tip = " · ".join(
- x
- for x in (
- app.get("label") or app.get("package"),
- app.get("package"),
- app.get("published"),
- published.get("suite") or "",
- app.get("source") or "deb.taler.net",
- )
- if x
- )
- name = str(app.get("label") or app.get("package") or "app")
- chips.append(
- f''
- f"{logo}"
- f'{_esc(name)} {_esc(pub)} '
- f" "
- )
- suite = published.get("suite") or ""
- apps_html = (
- f'published apps / SPAs'
- f'{(" · " + _esc(suite)) if suite else ""} · deb.taler.net '
- f'{"".join(chips)}
'
- )
-
- if apps_html:
- sections.append(apps_html)
- if ports_html:
- sections.append(ports_html)
- if orphan_html:
- sections.append(orphan_html)
-
- result_index_html = _render_result_index(doc)
-
- return f"""
-
-
-
-
-
-LLM Lightweight Monitoring · stacks
-
-
-
-
-{''.join(sections)}
-{result_index_html}
-LLM Lightweight Monitoring · OK · Present · Soft · Degraded · Down · Gone · click card for why
-
-
-
detail
- close
-
-
-
-
-
-
-"""
-
-
-def main() -> int:
- ap = argparse.ArgumentParser(description="Render stack status HTML from JSON")
- ap.add_argument("--status", type=Path, default=None)
- ap.add_argument("--out", type=Path, required=True)
- args = ap.parse_args()
- if L.load_monitoring_env():
- L.refresh_runtime_flags_from_env()
- doc = L.read_status(args.status)
- if not doc:
- doc = {"schema": L.SCHEMA, "ts": time.time(), "stacks": [], "legend": L.LEGEND}
- html_text = render_html(doc)
- args.out.parent.mkdir(parents=True, exist_ok=True)
- tmp = args.out.with_name(f".{args.out.name}.tmp.{os.getpid()}")
- try:
- tmp.write_text(html_text, encoding="utf-8")
- os.replace(tmp, args.out)
- finally:
- if tmp.exists():
- try:
- tmp.unlink()
- except OSError:
- pass
- print(args.out)
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/site-gen/stack_status_lib.py b/site-gen/stack_status_lib.py
deleted file mode 100644
index 045de76..0000000
--- a/site-gen/stack_status_lib.py
+++ /dev/null
@@ -1,4410 +0,0 @@
-#!/usr/bin/env python3
-"""Stack traffic lights for taler-monitoring (daemon → JSON; UI reads only)."""
-from __future__ import annotations
-
-import json
-import os
-import re
-import socket
-import ssl
-import subprocess
-import time
-import urllib.error
-import urllib.request
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from pathlib import Path
-from typing import Any, Callable
-
-SCHEMA = 8
-LIGHTS = ("green", "blue", "yellow", "orange", "red", "black")
-SEVERITY = {name: i for i, name in enumerate(LIGHTS)}
-# Soft endpoint fails (intro/terms/…) cap at yellow unless hard=True.
-ENDPOINT_SOFT_MAX = "yellow"
-
-
-def _env_truthy(name: str, default: str = "1") -> bool:
- return os.environ.get(name, default) not in ("0", "false", "no", "")
-
-
-def refresh_runtime_flags_from_env() -> None:
- """Re-read module toggles after load_monitoring_env() (import-time values go stale)."""
- global PUBLISHED_ENABLED, PUBLISHED_SUITE, PUBLISHED_APT_BASE
- global ACUTE_DOWN_S, PROBE_TIMEOUT_S, STATS_STALE_S, SSH_PROBE_TIMEOUT_S, TLS_VERIFY
- global SSH_PROBES_ENABLED, CVE_PROBES_ENABLED, SERVICES_ENABLED, MAIL_ENABLED
- global CERT_PROBES_ENABLED, ORPHAN_PROBES_ENABLED, ORPHAN_HTTP_ENABLED
- global MAIL_PORTS_ENABLED, MAIL_PORT_TIMEOUT_S
- global DELIVER_ENABLED, DELIVER_INTERVAL_S, DELIVER_STALE_S, DELIVER_TIMEOUT_S
- global PORT_SCAN_ENABLED, PORT_SCAN_TIMEOUT_S, PORT_SCAN_WORKERS
- global CERT_WARN_DAYS, CERT_CRIT_DAYS
- global SCORE_RATIO_GREEN, SCORE_RATIO_BLUE, SCORE_RATIO_YELLOW
- global SCORE_RATIO_ORANGE, SCORE_RATIO_RED
- global RESULT_INDEX_MODE
- global GIT_PROBE_ENABLED, GIT_PROBE_INTERVAL_S, GIT_PROBE_STALE_S, GIT_PROBE_TIMEOUT_S
- global PAY_ENABLED, PAY_RUN, PAY_INTERVAL_S, PAY_STALE_S, PAY_TIMEOUT_S, PAY_DOMAINS
- PUBLISHED_ENABLED = _env_truthy("TALER_MONITORD_PUBLISHED")
- PUBLISHED_SUITE = os.environ.get("TALER_MONITORD_APT_SUITE", "trixie")
- PUBLISHED_APT_BASE = os.environ.get(
- "TALER_MONITORD_APT_BASE", "https://deb.taler.net/apt/debian"
- ).rstrip("/")
- ACUTE_DOWN_S = float(os.environ.get("TALER_MONITORD_ACUTE_DOWN_S", "90"))
- PROBE_TIMEOUT_S = float(os.environ.get("TALER_MONITORD_PROBE_TIMEOUT_S", "5"))
- STATS_STALE_S = float(os.environ.get("TALER_MONITORD_STATS_STALE_S", str(6 * 3600)))
- SSH_PROBE_TIMEOUT_S = float(os.environ.get("TALER_MONITORD_SSH_TIMEOUT_S", "4"))
- TLS_VERIFY = _env_truthy("TALER_MONITORD_TLS_VERIFY", "0")
- SSH_PROBES_ENABLED = _env_truthy("TALER_MONITORD_SSH")
- CVE_PROBES_ENABLED = _env_truthy("TALER_MONITORD_CVE")
- SERVICES_ENABLED = _env_truthy("TALER_MONITORD_SERVICES")
- MAIL_ENABLED = _env_truthy("TALER_MONITORD_MAIL")
- CERT_PROBES_ENABLED = _env_truthy("TALER_MONITORD_CERT")
- ORPHAN_PROBES_ENABLED = _env_truthy("TALER_MONITORD_ORPHAN")
- ORPHAN_HTTP_ENABLED = _env_truthy("TALER_MONITORD_ORPHAN_HTTP", "0")
- MAIL_PORTS_ENABLED = _env_truthy("TALER_MONITORD_MAIL_PORTS")
- MAIL_PORT_TIMEOUT_S = float(os.environ.get("TALER_MONITORD_MAIL_PORT_TIMEOUT_S", "4"))
- DELIVER_ENABLED = _env_truthy("TALER_MONITORD_DELIVER")
- DELIVER_INTERVAL_S = float(
- os.environ.get("TALER_MONITORD_DELIVER_INTERVAL_S", str(6 * 3600))
- )
- DELIVER_STALE_S = float(
- os.environ.get("TALER_MONITORD_DELIVER_STALE_S", str(30 * 3600))
- )
- DELIVER_TIMEOUT_S = float(os.environ.get("TALER_MONITORD_DELIVER_TIMEOUT_S", "12"))
- PORT_SCAN_ENABLED = _env_truthy("TALER_MONITORD_PORTSCAN")
- PORT_SCAN_TIMEOUT_S = float(os.environ.get("TALER_MONITORD_PORTSCAN_TIMEOUT_S", "0.8"))
- PORT_SCAN_WORKERS = int(os.environ.get("TALER_MONITORD_PORTSCAN_WORKERS", "24"))
- CERT_WARN_DAYS = int(os.environ.get("TALER_MONITORD_CERT_WARN_DAYS", "30"))
- CERT_CRIT_DAYS = int(os.environ.get("TALER_MONITORD_CERT_CRIT_DAYS", "14"))
- SCORE_RATIO_GREEN = float(os.environ.get("TALER_MONITORD_SCORE_GREEN", "1.0"))
- SCORE_RATIO_BLUE = float(os.environ.get("TALER_MONITORD_SCORE_BLUE", "0.85"))
- SCORE_RATIO_YELLOW = float(os.environ.get("TALER_MONITORD_SCORE_YELLOW", "0.60"))
- SCORE_RATIO_ORANGE = float(os.environ.get("TALER_MONITORD_SCORE_ORANGE", "0.35"))
- SCORE_RATIO_RED = float(os.environ.get("TALER_MONITORD_SCORE_RED", "0.01"))
- # Optional bottom index: 0/off | 1/colors | cmd/full (+ concrete probe command)
- raw_idx = (os.environ.get("TALER_MONITORD_RESULT_INDEX") or "0").strip().lower()
- if raw_idx in ("0", "false", "no", "off", ""):
- RESULT_INDEX_MODE = "off"
- elif raw_idx in ("cmd", "full", "commands", "2"):
- RESULT_INDEX_MODE = "cmd"
- else:
- RESULT_INDEX_MODE = "colors"
- if RESULT_INDEX_MODE == "colors" and _env_truthy(
- "TALER_MONITORD_RESULT_INDEX_CMD", "0"
- ):
- RESULT_INDEX_MODE = "cmd"
- GIT_PROBE_ENABLED = _env_truthy("TALER_MONITORD_GIT")
- GIT_PROBE_INTERVAL_S = float(
- os.environ.get("TALER_MONITORD_GIT_INTERVAL_S", str(6 * 3600))
- )
- GIT_PROBE_STALE_S = float(
- os.environ.get("TALER_MONITORD_GIT_STALE_S", str(30 * 3600))
- )
- GIT_PROBE_TIMEOUT_S = float(os.environ.get("TALER_MONITORD_GIT_TIMEOUT_S", "12"))
- # Payability (wallet-cli zero-pay) — opt-in; not every monitord tick
- PAY_ENABLED = _env_truthy("TALER_MONITORD_PAY", "0")
- PAY_RUN = _env_truthy("TALER_MONITORD_PAY_RUN", "0")
- PAY_INTERVAL_S = float(os.environ.get("TALER_MONITORD_PAY_INTERVAL_S", str(4 * 3600)))
- PAY_STALE_S = float(os.environ.get("TALER_MONITORD_PAY_STALE_S", str(30 * 3600)))
- PAY_TIMEOUT_S = float(os.environ.get("TALER_MONITORD_PAY_TIMEOUT_S", "180"))
- raw_pay = (os.environ.get("TALER_MONITORD_PAY_DOMAINS") or "").strip()
- PAY_DOMAINS = [
- p.strip()
- for p in re.split(r"[\s,]+", raw_pay)
- if p.strip()
- ] or ["stage.taler-ops.ch"]
-
-
-# Placeholders; filled by refresh_runtime_flags_from_env() below.
-PUBLISHED_ENABLED = True
-PUBLISHED_SUITE = "trixie"
-PUBLISHED_APT_BASE = "https://deb.taler.net/apt/debian"
-ACUTE_DOWN_S = 90.0
-PROBE_TIMEOUT_S = 5.0
-STATS_STALE_S = float(6 * 3600)
-SSH_PROBE_TIMEOUT_S = 4.0
-TLS_VERIFY = False
-SSH_PROBES_ENABLED = True
-CVE_PROBES_ENABLED = True
-SERVICES_ENABLED = True
-MAIL_ENABLED = True
-CERT_PROBES_ENABLED = True
-ORPHAN_PROBES_ENABLED = True
-ORPHAN_HTTP_ENABLED = False
-MAIL_PORTS_ENABLED = True
-MAIL_PORT_TIMEOUT_S = 4.0
-DELIVER_ENABLED = True
-DELIVER_INTERVAL_S = float(6 * 3600)
-DELIVER_STALE_S = float(30 * 3600)
-DELIVER_TIMEOUT_S = 12.0
-# Outside-in notable port scan (public TCP only). Suspicious = open ∩ ¬whitelist ∩ ¬baseline.
-# Whitelist = operator-known intentional public opens — not an inventory of private topology.
-PORT_SCAN_ENABLED = True
-PORT_SCAN_TIMEOUT_S = 0.8
-PORT_SCAN_WORKERS = 24
-CERT_WARN_DAYS = 30
-CERT_CRIT_DAYS = 14
-SCORE_RATIO_GREEN = 1.0
-SCORE_RATIO_BLUE = 0.85
-SCORE_RATIO_YELLOW = 0.60
-SCORE_RATIO_ORANGE = 0.35
-SCORE_RATIO_RED = 0.01
-RESULT_INDEX_MODE = "off"
-GIT_PROBE_ENABLED = True
-GIT_PROBE_INTERVAL_S = float(6 * 3600)
-GIT_PROBE_STALE_S = float(30 * 3600)
-GIT_PROBE_TIMEOUT_S = 12.0
-PAY_ENABLED = False
-PAY_RUN = False
-PAY_INTERVAL_S = float(4 * 3600)
-PAY_STALE_S = float(30 * 3600)
-PAY_TIMEOUT_S = 180.0
-PAY_DOMAINS = ["stage.taler-ops.ch"]
-refresh_runtime_flags_from_env()
-# Notable TCP ports (not a full nmap range). Override: TALER_MONITORD_PORTSCAN_PORTS=22,80,443,…
-_DEFAULT_SCAN_PORTS = (
- 21,
- 22,
- 23,
- 25,
- 80,
- 110,
- 135,
- 139,
- 143,
- 443,
- 445,
- 465,
- 587,
- 993,
- 995,
- 1433,
- 1521,
- 1789,
- 1790,
- 2049,
- 3306,
- 3389,
- 5432,
- 5900,
- 6379,
- 8080,
- 8443,
- 9200,
- 9418,
- 11211,
- 23230,
- 23235,
- 23236,
- 23237,
- 23238,
- 27017,
- 52323,
-)
-# Baseline always-ok for public HTTPS endpoints (still overridable by denying via empty whitelist only —
-# whitelist *adds* allowance; baseline is the default allow for web).
-_BASELINE_WEB_PORTS = frozenset({80, 443})
-_BASELINE_MAIL_PORTS = frozenset({25, 110, 143, 465, 587, 993, 995})
-# Port → short label for UI
-_PORT_LABELS = {
- 21: "ftp",
- 22: "ssh",
- 23: "telnet",
- 25: "smtp",
- 80: "http",
- 110: "pop3",
- 135: "msrpc",
- 139: "netbios",
- 143: "imap",
- 443: "https",
- 445: "smb",
- 465: "smtps",
- 587: "submission",
- 993: "imaps",
- 995: "pop3s",
- 1433: "mssql",
- 1521: "oracle",
- 2049: "nfs",
- 3306: "mysql",
- 3389: "rdp",
- 5432: "postgres",
- 5900: "vnc",
- 6379: "redis",
- 8080: "http-alt",
- 8443: "https-alt",
- 1789: "nym-sphinx",
- 1790: "nym-verloc",
- 9200: "git-ssh",
- 9418: "git-daemon",
- 11211: "memcached",
- 23230: "ssh-dnat",
- 23235: "ssh-dnat",
- 23236: "ssh-dnat",
- 23237: "ssh-dnat",
- 23238: "ssh-dnat",
- 27017: "mongodb",
- 52323: "tortuna",
-}
-# Member reasons that are informational only (do not weaken the stack box).
-_AGG_SKIP_REASONS = frozenset({"ebics_iban", "ebics_no_bank"})
-
-# Suite-like public endpoints per machine (paths relative to https://host).
-# need_intro: custom /intro stack only (GOA + FrancPaysan/TESTPAYSAN stage).
-# severity: soft (≤yellow) | medium (missing → orange) | hard (full classify).
-ROLE_ENDPOINTS: dict[str, list[dict[str, Any]]] = {
- "bank": [
- {"id": "integration", "path": "/taler-integration/config", "expect": (200,), "severity": "hard"},
- {"id": "config", "path": "/config", "expect": (200,), "severity": "hard"},
- {"id": "webui", "path": "/webui/", "expect": (200, 301, 302), "severity": "soft"},
- {"id": "intro", "path": "/intro/", "expect": (200, 301, 302), "severity": "soft", "need_intro": True},
- {"id": "stats", "path": "/intro/stats.json", "expect": (200,), "severity": "soft", "need_intro": True},
- # Legal docs: severity overridden at probe time (medium only for commercial prod).
- {"id": "terms", "path": "/terms", "expect": (200,), "severity": "soft", "need_intro": True, "legal": True},
- {"id": "privacy", "path": "/privacy", "expect": (200,), "severity": "soft", "need_intro": True, "legal": True},
- ],
- "exchange": [
- {"id": "config", "path": "/config", "expect": (200,), "severity": "hard"},
- {"id": "keys", "path": "/keys", "expect": (200,), "severity": "hard"},
- {"id": "terms", "path": "/terms", "expect": (200,), "severity": "soft", "legal": True},
- {"id": "privacy", "path": "/privacy", "expect": (200,), "severity": "soft", "legal": True},
- {"id": "intro", "path": "/intro/", "expect": (200, 301, 302), "severity": "soft", "need_intro": True},
- ],
- "merchant": [
- {"id": "config", "path": "/config", "expect": (200,), "severity": "hard"},
- {"id": "webui", "path": "/webui/", "expect": (200, 301, 302), "severity": "soft"},
- {"id": "intro", "path": "/intro/", "expect": (200, 301, 302), "severity": "soft", "need_intro": True},
- {"id": "stats", "path": "/intro/stats.json", "expect": (200,), "severity": "soft", "need_intro": True},
- {"id": "terms", "path": "/terms", "expect": (200,), "severity": "soft", "legal": True},
- {"id": "privacy", "path": "/privacy", "expect": (200,), "severity": "soft", "legal": True},
- ],
- "site": [
- {"id": "apex", "path": "/", "expect": (200, 301, 302), "severity": "hard"},
- ],
-}
-
-
-def stack_has_custom_intro(st: dict[str, Any]) -> bool:
- """True only for custom /intro landings: GOA, FrancPaysan, TESTPAYSAN stage.
-
- Prefer domains.conf landing=1; also recognize by currency / hostname so
- demo/test/regio/TOPS never get /intro probes by accident.
- """
- cur = str(st.get("currency") or "").upper()
- if cur in ("GOA", "TESTPAYSAN"):
- return True
- blob = " ".join(
- [
- str(st.get("id") or ""),
- " ".join(str(a) for a in (st.get("aliases") or [])),
- str(st.get("bank") or ""),
- str(st.get("exchange") or ""),
- str(st.get("merchant") or ""),
- ]
- ).lower()
- if "hacktivism" in blob or "lefrancpaysan" in blob or "francpaysan" in blob:
- # static_pending FP prod has no Taler /intro APIs yet
- if (st.get("kind") or "") == "static_pending" or not cur:
- return False
- return True
- # Explicit landing=1 only if it is one of the known custom stacks above
- return False
-
-
-def stack_is_commercial_prod(st: dict[str, Any]) -> bool:
- """Commercial production (legal docs must be solid) — not stage/demo/test/GOA.
-
- Today: TOPS CHF prod (taler-ops.ch / my.taler-ops.ch). Stage and sandboxes stay soft.
- """
- if st.get("local"):
- return False
- if (st.get("kind") or "") == "static_pending":
- return False
- sid = str(st.get("id") or "").lower()
- blob = " ".join(
- [
- sid,
- " ".join(str(a) for a in (st.get("aliases") or [])),
- str(st.get("merchant") or ""),
- str(st.get("exchange") or ""),
- ]
- ).lower()
- if "stage." in sid or sid.startswith("stage.") or ".stage." in sid:
- return False
- if "stage." in blob and "taler-ops" in blob:
- return False
- # TOPS production CHF
- if "taler-ops.ch" in blob and "stage" not in sid:
- cur = str(st.get("currency") or "").upper()
- if cur in ("", "CHF"):
- return True
- return False
-
-
-def legal_doc_severity(st: dict[str, Any]) -> str:
- """terms/privacy: medium (orange) only for commercial production."""
- return "medium" if stack_is_commercial_prod(st) else "soft"
-
-
-def _stack_blob(st: dict[str, Any]) -> str:
- return " ".join(
- [
- str(st.get("id") or ""),
- " ".join(str(a) for a in (st.get("aliases") or [])),
- str(st.get("bank") or ""),
- str(st.get("exchange") or ""),
- str(st.get("merchant") or ""),
- str(st.get("currency") or ""),
- ]
- ).lower()
-
-
-def classify_network_zone(st: dict[str, Any]) -> dict[str, str]:
- """Map a stack to a coarse network area (not a full topology)."""
- blob = _stack_blob(st)
- cur = str(st.get("currency") or "").upper()
- if st.get("local") or "hacktivism" in blob or cur == "GOA":
- return {"zone": "goa-local", "label": "GOA / local (koopa)"}
- if cur == "TESTPAYSAN" or "stage.lefrancpaysan" in blob:
- return {"zone": "fp-stage", "label": "FrancPaysan stage"}
- if (st.get("kind") or "") == "static_pending" or (
- "lefrancpaysan" in blob and not cur
- ):
- return {"zone": "fp-edge", "label": "FrancPaysan edge (static)"}
- if stack_is_commercial_prod(st):
- return {"zone": "tops-prod", "label": "TOPS prod CHF"}
- if "stage.taler-ops" in blob or "stage.my.taler-ops" in blob:
- return {"zone": "tops-stage", "label": "TOPS stage"}
- if cur in ("KUDOS", "TESTKUDOS", "FLODOS") or "demo.taler.net" in blob or "test.taler.net" in blob:
- return {"zone": "public-demo", "label": "public demo/test"}
- if "taler-systems" in blob or "anastasis" in blob or "pixel" in blob:
- return {"zone": "pixel", "label": "pixel / TSA / Anastasis"}
- if "gnunet.org" in blob or (st.get("kind") or "") == "mail":
- zone = str(st.get("zone") or "")
- if zone == "mail" or (st.get("kind") or "") == "mail":
- return {"zone": "mail", "label": "mail plane"}
- return {"zone": "firefly", "label": "firefly / gnunet"}
- return {"zone": "public", "label": "public HTTPS"}
-
-
-def _env_ssh_hosts(*keys: str) -> list[str]:
- out: list[str] = []
- for k in keys:
- raw = (os.environ.get(k) or "").strip()
- if not raw:
- continue
- for part in re.split(r"[\s,]+", raw):
- if part and part not in out:
- out.append(part)
- return out
-
-
-def monitoring_env_path() -> Path:
- """Path to taler-monitoring-env install (~/.config/taler-monitoring/env)."""
- raw = (os.environ.get("TALER_MONITORING_ENV") or "").strip()
- if raw:
- return Path(raw)
- xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
- return Path(xdg) / "taler-monitoring" / "env"
-
-
-def _expand_env_value(val: str) -> str:
- """Expand $VAR / ${VAR} using current environ (shell-like, for env profiles)."""
-
- def repl(m: re.Match[str]) -> str:
- name = m.group(1) or m.group(2)
- return os.environ.get(name, "")
-
- return re.sub(
- r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)",
- repl,
- val,
- )
-
-
-def load_monitoring_env(*, path: Path | None = None, override: bool = True) -> Path | None:
- """Load KEY=value from taler-monitoring-env profile into os.environ (like lib.sh).
-
- Skips comments/blanks. Expands $HOME / $VAR. Returns path if loaded, else None.
- """
- p = path or monitoring_env_path()
- if not p.is_file():
- return None
- try:
- text = p.read_text(encoding="utf-8")
- except OSError:
- return None
- for line in text.splitlines():
- s = line.strip()
- if not s or s.startswith("#"):
- continue
- if s.startswith("export "):
- s = s[7:].lstrip()
- if "=" not in s:
- continue
- key, _, val = s.partition("=")
- key = key.strip()
- if not key or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key):
- continue
- if not override and key in os.environ:
- continue
- val = val.strip()
- if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
- val = val[1:-1]
- os.environ[key] = _expand_env_value(val)
- return p
-
-
-def ssh_targets_for_stack(st: dict[str, Any]) -> dict[str, Any]:
- """Hostnames to probe for an SSH daemon (port 22 banner) — not jump-host login.
-
- Zone-mapped SSH targets come only from taler-monitoring-env
- (`TALER_MONITORD_SSH_TOPS` / `_FP` / `_GOA` / `_TOPS_PROD`). No suite hardcodes.
- When env supplies hosts, hide them in UI/JSON display fields.
- """
- zone = classify_network_zone(st)["zone"]
- # Prefer apex / merchant hostname that is publicly meaningful
- candidates: list[str] = []
- for key in ("id", "merchant", "exchange", "bank"):
- h = _host_only(str(st.get(key) or ""))
- if h and h.upper() != "IBAN" and h not in candidates:
- candidates.append(h)
- env_map = {
- "goa-local": "TALER_MONITORD_SSH_GOA",
- "fp-stage": "TALER_MONITORD_SSH_FP",
- "tops-stage": "TALER_MONITORD_SSH_TOPS",
- "tops-prod": "TALER_MONITORD_SSH_TOPS_PROD",
- }
- env_hosts = _env_ssh_hosts(env_map[zone]) if zone in env_map else []
- if env_hosts:
- hosts = env_hosts
- hide_host = True
- else:
- hosts = candidates[:2]
- hide_host = False
- # Public demos: still check if SSH is exposed (security signal)
- expected = True
- if zone in ("fp-edge",):
- expected = bool(hosts)
- return {
- "expected": expected and bool(hosts),
- "hosts": hosts,
- "via": "tcp22-banner",
- "hide_host": hide_host,
- }
-
-
-def probe_ssh_host(host: str, *, timeout_s: float = SSH_PROBE_TIMEOUT_S) -> dict[str, Any]:
- """Check whether an SSH service answers (banner), not whether our keys work.
-
- SSH-2.0-… banner ⇒ present (ok/green). Connection refused / timeout ⇒ absent.
- """
- host = _host_only(host)
- if not host:
- return {"ok": False, "light": "blue", "reason": "no_host", "host": "", "elapsed": 0.0}
- t0 = time.monotonic()
- try:
- with socket.create_connection((host, 22), timeout=max(1.0, float(timeout_s))) as sock:
- sock.settimeout(max(1.0, float(timeout_s)))
- banner = sock.recv(256).decode("utf-8", "replace").strip().splitlines()
- line = banner[0] if banner else ""
- elapsed = time.monotonic() - t0
- if line.upper().startswith("SSH-"):
- return {
- "ok": True,
- "light": "green",
- "reason": line[:80],
- "host": host,
- "elapsed": round(elapsed, 3),
- "banner": line[:120],
- }
- return {
- "ok": False,
- "light": "orange",
- "reason": f"no_ssh_banner:{line[:60]}",
- "host": host,
- "elapsed": round(elapsed, 3),
- }
- except (socket.timeout, TimeoutError):
- return {
- "ok": False,
- "light": "orange",
- "reason": "timeout",
- "host": host,
- "elapsed": round(time.monotonic() - t0, 3),
- }
- except OSError as e:
- # refused / unreachable = no public SSH (often OK for hardened edge)
- return {
- "ok": False,
- "light": "blue",
- "reason": type(e).__name__,
- "host": host,
- "elapsed": round(time.monotonic() - t0, 3),
- }
-
-
-def build_network_status(
- st: dict[str, Any],
- *,
- members: list[dict[str, Any]] | None = None,
- ssh_cache: dict[str, dict[str, Any]] | None = None,
- ssh_probe_fn: Callable[[str], dict[str, Any]] | None = None,
-) -> dict[str, Any]:
- """Zone + public HTTPS summary + optional SSH reachability for the bundle."""
- zone = classify_network_zone(st)
- members = members or []
- # Public path: worst non-informational member light
- pub_lights = [
- m.get("light") or "black"
- for m in members
- if (m.get("reason") or "") not in _AGG_SKIP_REASONS
- ]
- if not pub_lights:
- pub_lights = [m.get("light") or "black" for m in members] or ["blue"]
- pub_worst = LIGHTS[max(SEVERITY.get(L, 5) for L in pub_lights)]
- targets = ssh_targets_for_stack(st)
- hide_host = bool(targets.get("hide_host"))
- ssh_block: dict[str, Any] = {
- "expected": bool(targets.get("expected")),
- "via": targets.get("via") or "",
- "candidates": [] if hide_host else list(targets.get("hosts") or []),
- "ok": None,
- "host": "",
- "light": "blue",
- "reason": "not_expected",
- "elapsed": 0.0,
- }
- if not SSH_PROBES_ENABLED:
- ssh_block["reason"] = "ssh_probes_disabled"
- ssh_block["light"] = "blue"
- elif targets.get("expected") and targets.get("hosts"):
- cache = ssh_cache if ssh_cache is not None else {}
- probe = ssh_probe_fn or probe_ssh_host
- best: dict[str, Any] | None = None
- for h in targets["hosts"]:
- if h not in cache:
- cache[h] = probe(h)
- got = cache[h]
- if got.get("ok"):
- best = got
- break
- if best is None:
- best = got
- if best:
- # Keep real probe host out of UI for mapped zones (e.g. betel ≠ stage FQDN).
- shown_host = "" if targets.get("hide_host") else (best.get("host") or "")
- reason = best.get("reason") or ""
- if targets.get("hide_host") and reason and not str(reason).upper().startswith("SSH-"):
- # Drop accidental hostname leakage in soft errors; keep SSH banner text.
- if shown_host and shown_host in str(reason):
- reason = "ssh_probe"
- ssh_block.update(
- {
- "ok": bool(best.get("ok")),
- "host": shown_host,
- "light": best.get("light") or ("green" if best.get("ok") else "orange"),
- "reason": reason,
- "elapsed": best.get("elapsed") or 0.0,
- }
- )
- return {
- "zone": zone["zone"],
- "label": zone["label"],
- "public_light": pub_worst,
- "ssh": ssh_block,
- }
-
-LEGEND = {
- "green": "ok",
- "blue": "present",
- "yellow": "degraded",
- "orange": "recent_or_hard_degraded",
- "red": "down_acute",
- "black": "down_long",
-}
-
-
-def monitord_state_dir() -> Path:
- base = os.environ.get("TALER_MONITORD_STATE") or os.environ.get("XDG_RUNTIME_DIR")
- if base:
- d = Path(base) / "taler-monitord"
- else:
- d = Path.home() / ".local" / "state" / "taler-monitord"
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-def monitord_data_dir() -> Path:
- raw = os.environ.get("TALER_MONITORD_DATA")
- if raw:
- d = Path(raw)
- else:
- d = Path.home() / ".local" / "share" / "taler-monitord"
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-def status_json_paths() -> list[Path]:
- return [monitord_state_dir() / "stack-status.json", monitord_data_dir() / "stack-status.json"]
-
-
-def default_domains_conf() -> Path:
- env = os.environ.get("TALER_DOMAINS_CONF") or ""
- if env and Path(env).is_file():
- return Path(env)
- here = Path(__file__).resolve().parent
- for p in (here.parent / "domains.conf", Path("domains.conf")):
- if p.is_file():
- return p
- return here.parent / "domains.conf"
-
-
-def _repo_conf(name: str, env_key: str) -> Path:
- env = os.environ.get(env_key) or ""
- if env and Path(env).is_file():
- return Path(env)
- here = Path(__file__).resolve().parent
- for p in (here.parent / name, Path(name)):
- if p.is_file():
- return p
- return here.parent / name
-
-
-def default_services_conf() -> Path:
- return _repo_conf("services-catalog.conf", "TALER_SERVICES_CONF")
-
-
-def default_mail_catalog() -> Path:
- return _repo_conf("mail-catalog.conf", "MAIL_CATALOG")
-
-
-def default_ports_whitelist_conf() -> Path:
- return _repo_conf("ports-whitelist.conf", "TALER_PORTS_WHITELIST")
-
-
-def _host_only(s: str) -> str:
- s = (s or "").strip()
- s = s.removeprefix("https://").removeprefix("http://")
- return s.split("/")[0].split(":")[0]
-
-
-# domains.conf bank column: no public libeufin / x-taler-bank host expected
-_BANK_SKIP_TOKENS = frozenset({"-", ".", "none", "n/a", "na", "ebics", "iban", "skip", "off"})
-
-
-def bank_host_configured(bank: str) -> bool:
- """False when domains.conf marks bank as absent (EBICS / IBAN wire)."""
- b = (bank or "").strip().lower()
- return bool(b) and b not in _BANK_SKIP_TOKENS
-
-
-def payto_wire_methods(blob: bytes | str | None) -> set[str]:
- """Extract payto://METHOD/… wire methods from exchange /keys (or similar) body."""
- if blob is None:
- return set()
- if isinstance(blob, bytes):
- text = blob.decode("utf-8", "replace")
- else:
- text = str(blob)
- return {m.lower() for m in re.findall(r"payto://([a-z0-9-]+)/", text, flags=re.I)}
-
-
-def classify_bank_backend(methods: set[str], *, bank_configured: bool = True) -> str:
- """Return x-taler-bank | ebics | unknown.
-
- EBICS/IBAN stacks (e.g. TOPS CHF) have no public bank.* — only payto://iban.
- Demo/stage libeufin stacks advertise payto://x-taler-bank/….
- Mixed (regio: both) still expects bank.* because x-taler-bank is present.
- """
- if not bank_configured:
- return "ebics"
- m = {x.lower() for x in (methods or set())}
- if "x-taler-bank" in m:
- return "x-taler-bank"
- if "iban" in m:
- return "ebics"
- return "unknown"
-
-
-def _looks_locale(tok: str) -> bool:
- t = tok.lower().replace("_", "-")
- return bool(t in ("fr-ch", "de-ch", "en-ch", "ch-fr", "ch-de", "ch-en") or (
- len(t) == 5 and t[2] == "-" and t[:2].isalpha() and t[3:].isalpha()
- ))
-
-
-def _looks_lang(tok: str) -> bool:
- return tok.lower() in ("en", "fr", "de", "eng", "fra", "deu", "ger", "german", "deutsch", "french", "english")
-
-
-def parse_domains_conf(path: Path | None = None) -> list[dict[str, Any]]:
- """Return unique stacks keyed by (bank, exchange, merchant)."""
- path = path or default_domains_conf()
- by_endpoints: dict[tuple[str, str, str], dict[str, Any]] = {}
- if not path.is_file():
- return []
-
- for raw in path.read_text(encoding="utf-8").splitlines():
- line = raw.split("#", 1)[0].strip()
- if not line:
- continue
- parts = line.split()
- if len(parts) < 5:
- continue
- name, bank, exchange, merchant, currency = parts[0], parts[1], parts[2], parts[3], parts[4]
- local_stack = parts[5] if len(parts) > 5 else "0"
- landing = parts[6] if len(parts) > 6 else ""
- rest = parts[7:]
-
- # Legacy: 7th field was canonical (contains a dot, not 0/1)
- if landing and landing not in ("0", "1") and "." in landing:
- rest = [landing] + rest
- landing = ""
-
- canon = name
- if rest:
- if _looks_lang(rest[0]):
- if len(rest) >= 2 and _looks_locale(rest[1]):
- canon = rest[2] if len(rest) >= 3 else name
- elif len(rest) >= 2 and "." in rest[1]:
- canon = rest[1]
- elif "." in rest[0]:
- canon = rest[0]
-
- bank_configured = bank_host_configured(bank)
- bank_h = _host_only(bank) if bank_configured else ""
- ex_h, mer_h = _host_only(exchange), _host_only(merchant)
- # Keep skip-token in key so EBICS rows do not collide with empty bank hosts.
- key = (bank_h or (bank.strip().lower() if bank.strip() else "-"), ex_h, mer_h)
- cur = currency if currency not in ("-", "") else ""
- # No currency ⇒ static site / Taler prod not provisioned yet (e.g. lefrancpaysan.ch).
- kind = "taler" if cur else "static_pending"
- row = {
- "id": canon,
- "bank": bank_h,
- "bank_configured": bank_configured,
- "exchange": ex_h,
- "merchant": mer_h,
- "currency": cur,
- "kind": kind,
- "local": local_stack in ("1", "true", "yes"),
- "landing": landing in ("1", "true", "yes"),
- "aliases": [name],
- }
- if key not in by_endpoints:
- by_endpoints[key] = row
- else:
- prev = by_endpoints[key]
- prev["aliases"].append(name)
- # Prefer non-service hostname as id when alias list grows
- if prev["id"] in (bank_h, ex_h, mer_h) and canon not in (bank_h, ex_h, mer_h):
- prev["id"] = canon
- elif canon == name and name not in (bank_h, ex_h, mer_h) and len(name) <= len(prev["id"]):
- # keep shorter apex-like id when equal quality
- if prev["id"] in prev["aliases"] and name.count(".") <= prev["id"].count("."):
- prev["id"] = canon
- if cur and not prev["currency"]:
- prev["currency"] = cur
- prev["kind"] = "taler"
-
- return sorted(by_endpoints.values(), key=lambda s: s["id"])
-
-
-def member_probe_url(role: str, host: str) -> str:
- host = _host_only(host)
- if role == "bank":
- # Prefer integration config (wallet-facing); fallback handled in probe
- return f"https://{host}/taler-integration/config"
- return f"https://{host}/config"
-
-
-def stats_probe_url(host: str) -> str:
- return f"https://{_host_only(host)}/intro/stats.json"
-
-
-def _amount_value(amount: str) -> str:
- """Strip CUR: prefix from Taler amount string."""
- s = (amount or "").strip()
- if ":" in s:
- return s.split(":", 1)[1]
- return s
-
-
-def _amount_sort_key(val: str) -> tuple:
- try:
- return (0, float(val))
- except (TypeError, ValueError):
- return (1, str(val))
-
-
-def extract_denom_values(blob: bytes | str | None, currency: str = "") -> list[str]:
- """Unique denomination values from exchange /keys body (best-effort)."""
- if blob is None:
- return []
- text = blob.decode("utf-8", "replace") if isinstance(blob, bytes) else str(blob)
- vals: set[str] = set()
- # "value": "CUR:N" or amount fields
- for m in re.finditer(r'"value"\s*:\s*"([^"]+)"', text):
- raw = m.group(1)
- if currency and raw.startswith(currency + ":"):
- vals.add(_amount_value(raw))
- elif ":" in raw and re.match(r"^[A-Z][A-Z0-9_]+:", raw):
- vals.add(_amount_value(raw))
- # Prefer positive coin values; drop zero-only noise
- cleaned = [v for v in vals if v not in ("", "0", "0.0", "0.00")]
- return sorted(cleaned, key=_amount_sort_key)[:32]
-
-
-def summarize_currency_spec(
- cs: dict[str, Any] | None,
- *,
- keys_body: bytes | str | None = None,
- fallback_currency: str = "",
-) -> dict[str, Any] | None:
- """Currency code, coin symbols (alt_unit_names), denomination list for UI."""
- cs = cs if isinstance(cs, dict) else {}
- currency = str(cs.get("currency") or fallback_currency or "").strip()
- alt_raw = cs.get("alt_unit_names") if isinstance(cs.get("alt_unit_names"), dict) else {}
- alt = {str(k): str(v) for k, v in alt_raw.items() if str(v).strip()}
- if not currency and not alt:
- return None
- symbol = alt.get("0") or currency
- # Subunits: "-2" → Rp./ct — sorted by exponent
- def _exp_key(k: str) -> int:
- try:
- return int(k)
- except ValueError:
- return 0
-
- subunits: list[dict[str, str]] = []
- for k in sorted((x for x in alt if x != "0"), key=_exp_key, reverse=True):
- subunits.append({"exp": k, "symbol": alt[k]})
-
- denoms: list[str] = []
- for a in cs.get("common_amounts") or []:
- v = _amount_value(str(a))
- if v and v not in denoms:
- denoms.append(v)
- if not denoms and keys_body is not None:
- denoms = extract_denom_values(keys_body, currency)
-
- unit_bits = [symbol] + [s["symbol"] for s in subunits]
- # unique preserve order
- seen: set[str] = set()
- units: list[str] = []
- for u in unit_bits:
- if u and u not in seen:
- seen.add(u)
- units.append(u)
-
- return {
- "currency": currency,
- "name": str(cs.get("name") or ""),
- "symbol": symbol,
- "alt_unit_names": alt,
- "subunits": subunits,
- "units_label": " / ".join(units) if units else currency,
- "denoms": denoms,
- "denoms_label": " · ".join(denoms[:16]),
- }
-
-
-def _json_body(raw: dict[str, Any] | None) -> dict[str, Any] | None:
- if not raw:
- return None
- body = raw.get("body")
- if body is None:
- return None
- if isinstance(body, bytes):
- text = body.decode("utf-8", "replace")
- else:
- text = str(body)
- text = text.strip()
- if not text:
- return None
- try:
- data = json.loads(text)
- except json.JSONDecodeError:
- return None
- return data if isinstance(data, dict) else None
-
-
-def summarize_landing_stats(
- data: dict[str, Any],
- *,
- now: float | None = None,
- stale_s: float = STATS_STALE_S,
- role: str = "",
- host: str = "",
- url: str = "",
-) -> dict[str, Any]:
- """Compact, UI-friendly summary of public …/intro/stats.json (no full dump)."""
- now = time.time() if now is None else now
- gu = data.get("generated_at_unix")
- age: int | None = None
- if gu is not None and str(gu).strip() != "":
- try:
- src_u = int(float(gu))
- if src_u > 10_000_000_000:
- src_u //= 1000
- age = int(now - src_u)
- except (TypeError, ValueError):
- age = None
-
- lines: list[str] = []
- ba = data.get("bank_accounts") if isinstance(data.get("bank_accounts"), dict) else {}
- wd = data.get("withdraws") if isinstance(data.get("withdraws"), dict) else {}
- if ba or wd:
- n_acc = ba.get("total") if ba.get("total") is not None else ba.get("users")
- if n_acc is not None:
- lines.append(f"accounts {n_acc}")
- if wd.get("count") is not None:
- lines.append(f"withdraws {wd.get('count')}")
- amt = wd.get("total_amount_alt") or wd.get("total_amount")
- if amt:
- lines.append(str(amt))
- if "reserves" in data:
- lines.append(f"reserves {data.get('reserves')}")
- if data.get("wire_in_count") is not None:
- lines.append(f"wire_in {data.get('wire_in_count')}")
- coins = data.get("known_coins")
- if coins is None:
- coins = data.get("coins_live")
- if coins is not None:
- lines.append(f"coins {coins}")
- if data.get("instances") is not None:
- lines.append(f"instances {data.get('instances')}")
- if data.get("orders") is not None:
- lines.append(f"orders {data.get('orders')}")
- bal = data.get("balance_explorer_alt") or data.get("balance_explorer")
- if bal and not any(str(bal) in x for x in lines):
- lines.append(f"explorer {bal}")
-
- return {
- "present": True,
- "ok": data.get("ok") is not False,
- "age_s": age,
- "stale": bool(age is not None and age > stale_s),
- "generated_at_human": data.get("generated_at_human") or data.get("generated_at") or "",
- "source": str(data.get("source") or "")[:72],
- "currency": data.get("currency") or "",
- "lines": lines[:6],
- "role": role,
- "host": host,
- "url": url,
- }
-
-
-def fetch_stack_stats(
- st: dict[str, Any],
- *,
- bank_backend: str,
- probe_fn: Callable[[str], dict[str, Any]],
- now: float | None = None,
- stale_s: float = STATS_STALE_S,
-) -> dict[str, Any] | None:
- """Try public /intro/stats.json (custom intro stacks only). Soft-miss → None."""
- now = time.time() if now is None else now
- kind = st.get("kind") or "taler"
- if kind == "static_pending":
- return None
- if not stack_has_custom_intro(st):
- return None
-
- candidates: list[tuple[str, str]] = []
- if bank_backend == "x-taler-bank" and bank_host_configured(st.get("bank") or ""):
- candidates.append(("bank", _host_only(st["bank"])))
- if st.get("merchant"):
- candidates.append(("merchant", _host_only(st["merchant"])))
- if st.get("exchange"):
- candidates.append(("exchange", _host_only(st["exchange"])))
- # de-dupe hosts while keeping order
- seen: set[str] = set()
- ordered: list[tuple[str, str]] = []
- for role, host in candidates:
- if not host or host in seen:
- continue
- seen.add(host)
- ordered.append((role, host))
-
- for role, host in ordered:
- url = stats_probe_url(host)
- raw = probe_fn(url)
- if not raw.get("ok"):
- continue
- data = _json_body(raw)
- if not data:
- continue
- return summarize_landing_stats(
- data, now=now, stale_s=stale_s, role=role, host=host, url=url
- )
- return None
-
-
-def _ssl_context() -> ssl.SSLContext | None:
- if TLS_VERIFY:
- return None
- ctx = ssl.create_default_context()
- ctx.check_hostname = False
- ctx.verify_mode = ssl.CERT_NONE
- return ctx
-
-
-def _header_server(headers: Any) -> str:
- if headers is None:
- return ""
- try:
- return str(headers.get("Server") or headers.get("server") or "").strip()
- except Exception:
- return ""
-
-
-def probe_http(url: str, *, timeout: float = PROBE_TIMEOUT_S) -> dict[str, Any]:
- """Probe one URL. Returns ok/code/elapsed/error/server (no light yet)."""
- t0 = time.monotonic()
- req = urllib.request.Request(url, method="GET", headers={"User-Agent": "taler-monitord/1"})
- try:
- with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
- body = resp.read(65536)
- code = getattr(resp, "status", None) or resp.getcode()
- elapsed = time.monotonic() - t0
- return {
- "ok": 200 <= int(code) < 400,
- "code": int(code),
- "elapsed": elapsed,
- "error": None,
- "bytes": len(body),
- "body": body,
- "server": _header_server(getattr(resp, "headers", None)),
- "url": url,
- }
- except urllib.error.HTTPError as e:
- elapsed = time.monotonic() - t0
- try:
- err_body = e.read(65536) or b""
- except Exception:
- err_body = b""
- return {
- "ok": False,
- "code": int(e.code),
- "elapsed": elapsed,
- "error": f"http_{e.code}",
- "bytes": len(err_body),
- "body": err_body,
- "server": _header_server(getattr(e, "headers", None)),
- "url": url,
- }
- except (urllib.error.URLError, TimeoutError, socket.timeout, OSError) as e:
- elapsed = time.monotonic() - t0
- err = type(e).__name__
- reason = getattr(e, "reason", None)
- if reason is not None:
- err = f"{err}:{reason}"
- return {
- "ok": False,
- "code": 0,
- "elapsed": elapsed,
- "error": err,
- "bytes": 0,
- "body": b"",
- "server": "",
- "url": url,
- }
-
-
-def guess_http_package(server: str) -> str:
- s = (server or "").lower()
- if "nginx" in s:
- return "nginx"
- if "apache" in s or "httpd" in s:
- return "apache"
- if "caddy" in s:
- return "caddy"
- return ""
-
-
-def extract_http_version(server: str) -> str:
- m = re.search(r"/([0-9]+(?:\.[0-9]+)*)", server or "")
- return m.group(1) if m else ""
-
-
-def _taler_pkg_from_name(name: str) -> str:
- n = (name or "").lower()
- if "exchange" in n:
- return "taler-exchange"
- if "merchant" in n:
- return "taler-merchant"
- if "corebank" in n or n.endswith("bank") or "libeufin" in n:
- return "taler-bank"
- if "bank-integration" in n or "integration" in n:
- return "taler-bank"
- if n.startswith("taler"):
- return "taler"
- return ""
-
-
-def software_from_taler_config(body: bytes | str | dict | None, *, host: str = "", role: str = "") -> list[dict[str, Any]]:
- """Extract Taler component name + build_version from /config JSON."""
- if isinstance(body, dict):
- data = body
- elif body is None:
- data = None
- else:
- data = _json_body({"body": body})
- if not isinstance(data, dict):
- return []
- name = str(data.get("name") or "")
- pkg = _taler_pkg_from_name(name)
- if not pkg and role in ("exchange", "merchant", "bank"):
- pkg = {"exchange": "taler-exchange", "merchant": "taler-merchant", "bank": "taler-bank"}[role]
- if not pkg:
- return []
- build = str(data.get("build_version") or "").strip()
- api = str(data.get("version") or "").strip()
- ver = build or api
- return [
- {
- "package": pkg,
- "label": name or pkg,
- "version": ver,
- "api_version": api,
- "build_version": build,
- "host": _host_only(host),
- "source": "taler-config",
- "role": role,
- }
- ]
-
-
-def software_from_server_header(server: str, *, host: str = "") -> list[dict[str, Any]]:
- pkg = guess_http_package(server)
- if not pkg:
- return []
- ver = extract_http_version(server)
- return [
- {
- "package": pkg,
- "label": pkg,
- "version": ver,
- "host": _host_only(host),
- "source": "server-header",
- "server": (server or "")[:80],
- }
- ]
-
-
-def smtp_banner_software(host: str, *, timeout_s: float = 4.0) -> list[dict[str, Any]]:
- """Read SMTP 220 banner; detect Postfix (+ version if present)."""
- h = _host_only(host)
- if not h:
- return []
- try:
- with socket.create_connection((h, 25), timeout=max(1.0, float(timeout_s))) as sock:
- sock.settimeout(max(1.0, float(timeout_s)))
- banner = sock.recv(512).decode("utf-8", "replace").strip().splitlines()
- line = banner[0] if banner else ""
- try:
- sock.sendall(b"QUIT\r\n")
- except OSError:
- pass
- except OSError:
- return []
- low = line.lower()
- if "postfix" not in low:
- if "esmtp" in low or line.startswith("220"):
- return [
- {
- "package": "mta",
- "label": "SMTP",
- "version": "",
- "host": h,
- "source": "smtp-banner",
- "banner": line[:100],
- }
- ]
- return []
- ver = ""
- m = re.search(r"postfix[^\d]*([0-9]+(?:\.[0-9]+)*)", line, flags=re.I)
- if m:
- ver = m.group(1)
- return [
- {
- "package": "postfix",
- "label": "Postfix",
- "version": ver,
- "host": h,
- "source": "smtp-banner",
- "banner": line[:100],
- }
- ]
-
-
-def _sev_to_light(sev: str) -> str:
- s = (sev or "").upper()
- if s == "CRITICAL":
- return "red"
- if s == "HIGH":
- return "orange"
- if s == "MEDIUM":
- return "yellow"
- if s == "LOW":
- return "blue"
- return "green"
-
-
-_PUBLISHED_CACHE: dict[str, Any] = {"ts": 0.0, "pkgs": {}, "apps": [], "suite": "", "error": None}
-
-# inventory package → deb.taler.net package name(s)
-_SOFT_TO_DEB = {
- "taler-exchange": ("taler-exchange",),
- "taler-merchant": ("taler-merchant",),
- "taler-bank": ("libeufin-bank",),
- "taler": ("taler-exchange",),
-}
-
-# Published apps / SPAs we surface even without a live probe
-_APP_CATALOG = (
- {"package": "taler-merchant-webui", "label": "Merchant WebUI", "kind": "webui", "logo": "webui"},
- {"package": "libeufin-bank-webui", "label": "Bank WebUI", "kind": "webui", "logo": "webui"},
- {"package": "taler-exchange-kyc-webui", "label": "KYC WebUI", "kind": "webui", "logo": "webui"},
- {"package": "taler-exchange-aml-webui", "label": "AML WebUI", "kind": "webui", "logo": "webui"},
- {"package": "taler-wallet-cli", "label": "Wallet CLI", "kind": "wallet", "logo": "wallet"},
-)
-
-
-def _deb_ord_ch(c: str) -> int:
- if c == "~":
- return -1
- if c.isdigit():
- return 0
- if c.isalpha():
- return ord(c)
- return ord(c) + 256
-
-
-def _deb_split(v: str) -> tuple[int, str, str]:
- epoch = 0
- if ":" in v:
- e, v = v.split(":", 1)
- try:
- epoch = int(e)
- except ValueError:
- epoch = 0
- if "-" in v:
- upstream, deb = v.rsplit("-", 1)
- else:
- upstream, deb = v, ""
- return epoch, upstream, deb
-
-
-def _deb_cmp_part(a: str, b: str) -> int:
- ia = ib = 0
- while ia < len(a) or ib < len(b):
- while True:
- ca = a[ia] if ia < len(a) and not a[ia].isdigit() else ""
- cb = b[ib] if ib < len(b) and not b[ib].isdigit() else ""
- if ca == "" and cb == "":
- break
- if ca == "" and cb:
- return -1 if _deb_ord_ch(cb) > 0 else (1 if cb == "~" else -1)
- if cb == "" and ca:
- return 1 if _deb_ord_ch(ca) > 0 else (-1 if ca == "~" else 1)
- oa, ob = _deb_ord_ch(ca), _deb_ord_ch(cb)
- ia += 1
- ib += 1
- if oa != ob:
- return (oa > ob) - (oa < ob)
- sa = sb = ""
- while ia < len(a) and a[ia].isdigit():
- sa += a[ia]
- ia += 1
- while ib < len(b) and b[ib].isdigit():
- sb += b[ib]
- ib += 1
- na = int(sa) if sa else 0
- nb = int(sb) if sb else 0
- if na != nb:
- return (na > nb) - (na < nb)
- return 0
-
-
-def deb_version_cmp(a: str, b: str) -> int:
- """Debian-policy-ish version compare (subset used by check_versions.sh)."""
- if a == b:
- return 0
- ea, ua, da = _deb_split(a or "")
- eb, ub, db = _deb_split(b or "")
- if ea != eb:
- return (ea > eb) - (ea < eb)
- c = _deb_cmp_part(ua, ub)
- if c:
- return c
- return _deb_cmp_part(da, db)
-
-
-def normalize_build_version(v: str) -> str:
- """Map Taler build_version toward deb upstream form for comparison.
-
- Examples: ``X.Y.Z-dev.1`` → ``X.Y.Z~dev1``; plain ``X.Y.Z`` stays.
- """
- s = (v or "").strip()
- if not s:
- return ""
- # API versions like 39:0:5 are not package versions
- if re.fullmatch(r"\d+:\d+:\d+", s):
- return ""
- s = re.sub(r"-dev\.(\d+)", r"~dev\1", s, flags=re.I)
- s = re.sub(r"-dev(\d+)", r"~dev\1", s, flags=re.I)
- s = s.replace("-dev", "~dev")
- return s
-
-
-def parse_apt_packages_index(text: str) -> dict[str, str]:
- """Return package → highest Version from a Packages file body."""
- pkgs: dict[str, str] = {}
- cur = ver = None
- for line in (text or "").splitlines():
- if line.startswith("Package: "):
- if cur and ver and (cur not in pkgs or deb_version_cmp(ver, pkgs[cur]) > 0):
- pkgs[cur] = ver
- cur = line[9:].strip()
- ver = None
- elif line.startswith("Version: ") and cur:
- ver = line[9:].strip()
- elif line == "" and cur:
- if ver and (cur not in pkgs or deb_version_cmp(ver, pkgs[cur]) > 0):
- pkgs[cur] = ver
- cur = ver = None
- if cur and ver and (cur not in pkgs or deb_version_cmp(ver, pkgs[cur]) > 0):
- pkgs[cur] = ver
- return pkgs
-
-
-def fetch_published_taler_packages(
- *,
- suite: str | None = None,
- apt_base: str | None = None,
- cache_s: float = 3600.0,
-) -> dict[str, Any]:
- """Fetch/cached deb.taler.net Packages for published Taler versions."""
- if not PUBLISHED_ENABLED:
- return {"enabled": False, "suite": suite or PUBLISHED_SUITE, "packages": {}, "apps": []}
- suite = suite or PUBLISHED_SUITE
- apt_base = (apt_base or PUBLISHED_APT_BASE).rstrip("/")
- now = time.time()
- if (
- _PUBLISHED_CACHE.get("pkgs")
- and _PUBLISHED_CACHE.get("suite") == suite
- and now - float(_PUBLISHED_CACHE.get("ts") or 0) < cache_s
- ):
- return {
- "enabled": True,
- "suite": suite,
- "packages": dict(_PUBLISHED_CACHE["pkgs"]),
- "apps": list(_PUBLISHED_CACHE.get("apps") or []),
- "error": _PUBLISHED_CACHE.get("error"),
- "cached": True,
- }
- url = f"{apt_base}/dists/{suite}/main/binary-amd64/Packages"
- # Full Packages index is >>64KiB (probe_http truncates); fetch with a larger cap.
- pkgs: dict[str, str] = {}
- err = None
- try:
- req = urllib.request.Request(url, method="GET", headers={"User-Agent": "taler-monitord/1"})
- with urllib.request.urlopen(req, timeout=20, context=_ssl_context()) as resp:
- body = resp.read(8_000_000)
- code = getattr(resp, "status", None) or resp.getcode()
- if 200 <= int(code) < 400 and body:
- text = body.decode("utf-8", "replace")
- pkgs = parse_apt_packages_index(text)
- else:
- err = f"http_{code}"
- except Exception as e:
- err = f"{type(e).__name__}:{e}"
- # fallback tiny probe (may be incomplete)
- raw = probe_http(url, timeout=max(8.0, PROBE_TIMEOUT_S))
- if raw.get("ok") and raw.get("body"):
- body = raw["body"]
- text = body.decode("utf-8", "replace") if isinstance(body, bytes) else str(body)
- pkgs = parse_apt_packages_index(text)
- apps = []
- for app in _APP_CATALOG:
- pub = pkgs.get(app["package"]) or ""
- apps.append(
- {
- **app,
- "published": pub,
- "published_upstream": _deb_split(pub)[1] if pub else "",
- "light": "green" if pub else "yellow",
- "status": "published" if pub else "missing",
- }
- )
- # Wallet tarball from GNU FTP (best-effort latest numbered)
- try:
- ftp = probe_http("https://ftp.gnu.org/gnu/taler/", timeout=8.0)
- if ftp.get("ok") and ftp.get("body"):
- html = ftp["body"].decode("utf-8", "replace") if isinstance(ftp["body"], bytes) else str(ftp["body"])
- vers = re.findall(r"taler-wallet-([0-9]+\.[0-9]+(?:\.[0-9]+)?)\.tar\.gz", html)
- if vers:
- latest = sorted(vers, key=lambda v: _ver_tuple(v))[-1]
- apps.append(
- {
- "package": "taler-wallet",
- "label": "Wallet (GNU FTP)",
- "kind": "wallet",
- "logo": "wallet",
- "published": latest,
- "published_upstream": latest,
- "light": "green",
- "status": "published",
- "source": "ftp.gnu.org",
- }
- )
- except Exception:
- pass
- _PUBLISHED_CACHE.update({"ts": now, "pkgs": pkgs, "apps": apps, "suite": suite, "error": err})
- return {
- "enabled": True,
- "suite": suite,
- "packages": pkgs,
- "apps": apps,
- "error": err,
- "cached": False,
- "url": url,
- }
-
-
-def soft_to_deb_names(item: dict[str, Any]) -> list[str]:
- pkg = str(item.get("package") or "")
- label = str(item.get("label") or "").lower()
- if pkg == "taler-bank" and "integration" in label:
- return [] # API version only, not a deb package build
- return list(_SOFT_TO_DEB.get(pkg) or ())
-
-
-def version_currency(
- installed: str,
- published_deb: str,
-) -> dict[str, Any]:
- """Compare installed build_version to published deb version; estimate lag."""
- inst_n = normalize_build_version(installed)
- pub_up = _deb_split(published_deb)[1] if published_deb else ""
- if not inst_n or not pub_up:
- return {
- "status": "unknown",
- "light": "blue",
- "published": published_deb,
- "published_upstream": pub_up,
- "behind": None,
- "delta": None,
- }
- # Compare upstream-like strings via deb rules
- cmp = deb_version_cmp(inst_n, pub_up)
- if cmp == 0:
- return {
- "status": "current",
- "light": "green",
- "published": published_deb,
- "published_upstream": pub_up,
- "behind": None,
- "delta": 0,
- }
- if cmp > 0:
- return {
- "status": "ahead",
- "light": "blue",
- "published": published_deb,
- "published_upstream": pub_up,
- "behind": None,
- "delta": None,
- }
- # behind — try numeric distance on dotted prefix
- ti, tp = _ver_tuple(inst_n.replace("~", ".")), _ver_tuple(pub_up.replace("~", "."))
- delta = None
- if ti and tp and len(ti) >= 2 and len(tp) >= 2:
- # rough component-wise lag on last differing field
- n = max(len(ti), len(tp))
- ti = ti + (0,) * (n - len(ti))
- tp = tp + (0,) * (n - len(tp))
- for i in range(n):
- if tp[i] > ti[i]:
- delta = int(tp[i] - ti[i])
- break
- return {
- "status": "behind",
- "light": "orange" if (delta is None or delta > 0) else "yellow",
- "published": published_deb,
- "published_upstream": pub_up,
- "behind": pub_up,
- "delta": delta,
- }
-
-
-def apply_published_currency(
- items: list[dict[str, Any]],
- published: dict[str, Any] | None,
-) -> list[dict[str, Any]]:
- """Attach published/current/behind fields; raise light when behind."""
- pkgs = (published or {}).get("packages") or {}
- out = []
- for it in items:
- row = dict(it)
- deb_names = soft_to_deb_names(row)
- best = None
- for dn in deb_names:
- pub = pkgs.get(dn) or ""
- if not pub:
- continue
- cur = version_currency(str(row.get("version") or ""), pub)
- cur["deb_package"] = dn
- if best is None or (
- cur.get("status") == "behind"
- and best.get("status") != "behind"
- ):
- best = cur
- elif cur.get("status") == "behind" and best.get("status") == "behind":
- # keep higher published upstream
- if deb_version_cmp(str(cur.get("published") or ""), str(best.get("published") or "")) > 0:
- best = cur
- if best:
- row.update(
- {
- "published": best.get("published"),
- "published_upstream": best.get("published_upstream"),
- "currency_status": best.get("status"),
- "behind": best.get("behind"),
- "delta": best.get("delta"),
- "deb_package": best.get("deb_package"),
- }
- )
- # CVE colour wins if worse; else use currency light
- if best.get("status") == "behind":
- if SEVERITY.get(str(best.get("light")), 0) > SEVERITY.get(str(row.get("light") or "green"), 0):
- row["light"] = best.get("light")
- elif best.get("status") == "current" and row.get("light") in ("green", "blue", None):
- row["light"] = "green"
- else:
- row.setdefault("currency_status", "n/a")
- out.append(row)
- return out
-
-
-def colorize_software(
- items: list[dict[str, Any]],
- vulns: dict[str, Any] | None = None,
- published: dict[str, Any] | None = None,
-) -> list[dict[str, Any]]:
- """Dedupe + CVE colour + published currency (current/behind/ahead)."""
- hits = (vulns or {}).get("hits") or []
- by_pkg_ver: dict[str, str] = {}
- for h in hits:
- pkg = str(h.get("package") or "")
- ver = str(h.get("version") or "")
- sev = str(h.get("severity") or "")
- key = f"{pkg}|{ver}"
- light = _sev_to_light(sev)
- if key not in by_pkg_ver or SEVERITY.get(light, 0) > SEVERITY.get(by_pkg_ver[key], 0):
- by_pkg_ver[key] = light
- out: list[dict[str, Any]] = []
- seen: set[str] = set()
- for it in items:
- pkg = str(it.get("package") or "")
- ver = str(it.get("version") or "")
- host = str(it.get("host") or "")
- key = f"{pkg}|{ver}|{host}|{it.get('source')}"
- if not pkg or key in seen:
- continue
- seen.add(key)
- light = by_pkg_ver.get(f"{pkg}|{ver}") or ("green" if ver else "blue")
- out.append({**it, "light": light})
- out = apply_published_currency(out, published)
- order = {
- "taler-exchange": 0,
- "taler-merchant": 1,
- "taler-bank": 2,
- "taler": 3,
- "nginx": 10,
- "apache": 11,
- "caddy": 12,
- "postfix": 20,
- "mta": 21,
- }
- out.sort(key=lambda x: (order.get(str(x.get("package")), 50), str(x.get("package")), str(x.get("host"))))
- return out
-
-
-def collect_software_from_probes(
- *,
- probe_cache: dict[str, dict[str, Any]],
- members: list[dict[str, Any]],
- vulns: dict[str, Any] | None = None,
- mail_hosts: list[str] | None = None,
- smtp: bool = False,
- published: dict[str, Any] | None = None,
-) -> list[dict[str, Any]]:
- """Assemble software inventory for one card from HTTP/Taler/SMTP signals."""
- items: list[dict[str, Any]] = []
- for url, raw in (probe_cache or {}).items():
- host = _host_only(url)
- srv = str((raw or {}).get("server") or "")
- items.extend(software_from_server_header(srv, host=host))
- # Taler /config or /keys-adjacent config bodies
- path = url.split(host, 1)[-1] if host and host in url else ""
- if path.endswith("/config") or "/taler-integration/config" in path:
- role = ""
- for m in members or []:
- if _host_only(str(m.get("host") or "")) == host:
- role = str(m.get("role") or "")
- break
- items.extend(software_from_taler_config((raw or {}).get("body"), host=host, role=role))
- if smtp:
- for h in mail_hosts or []:
- items.extend(smtp_banner_software(h))
- return colorize_software(items, vulns, published=published)
-
-
-def _ver_tuple(s: str) -> tuple[int, ...]:
- nums = [int(x) for x in re.findall(r"\d+", s or "")[:5]]
- return tuple(nums) if nums else ()
-
-
-def _vt_cmp(a: tuple[int, ...], b: tuple[int, ...]) -> int:
- 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: str, events: list) -> bool:
- vt = _ver_tuple(ver)
- if not vt:
- return False
- 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
- return False
-
-
-_OSV_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"],
-}
-
-
-def _osv_get(vid: str) -> dict[str, Any]:
- try:
- with urllib.request.urlopen(f"https://api.osv.dev/v1/vulns/{vid}", timeout=12) as r:
- return json.loads(r.read().decode("utf-8", "replace"))
- except Exception:
- return {}
-
-
-def _severity_rank(sev: str) -> int:
- s = (sev or "").upper()
- if s == "CRITICAL":
- return 3
- if s == "HIGH":
- return 2
- if s == "MEDIUM":
- return 1
- return 0
-
-
-def osv_hits_for_server(pkg: str, ver: str, *, cache: dict[str, list] | None = None) -> list[dict[str, Any]]:
- """Actionable OSV hits for a bare upstream Server header version (no Debian rev).
-
- Same policy as check_surface.sh: use SEMVER extracted_events on known CVE seeds
- — avoids Debian ecosystem false positives on bare versions.
- """
- if not pkg or not ver:
- return []
- cache = cache if cache is not None else {}
- key = f"{pkg}|{ver}"
- if key in cache:
- return list(cache[key])
- hits: list[dict[str, Any]] = []
- for vid in _OSV_SEED.get(pkg, []):
- doc = _osv_get(vid)
- if not doc:
- continue
- ok_hit = False
- for a in doc.get("affected") or []:
- 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):
- blob = json.dumps(a).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 not ok_hit:
- continue
- sev = ""
- for s in doc.get("severity") or []:
- if isinstance(s, dict) and s.get("type") in ("CVSS_V3", "CVSS_V4", "CVSS_V2"):
- # score-based
- score = s.get("score")
- try:
- sc = float(str(score).split("/")[0]) if score else 0.0
- except ValueError:
- sc = 0.0
- if sc >= 9.0:
- sev = "CRITICAL"
- elif sc >= 7.0:
- sev = "HIGH"
- elif sc >= 4.0:
- sev = "MEDIUM"
- break
- dbsev = ((doc.get("database_specific") or {}).get("severity") or "").upper()
- if dbsev in ("CRITICAL", "HIGH", "MEDIUM", "LOW") and _severity_rank(dbsev) > _severity_rank(sev):
- sev = dbsev
- if not sev:
- sev = "HIGH" # known actionable seed hit without score → treat serious
- hits.append(
- {
- "id": vid,
- "severity": sev,
- "package": pkg,
- "version": ver,
- "summary": str(doc.get("summary") or "")[:160],
- }
- )
- # keep critical/high only for box colour; still return medium for display
- cache[key] = hits
- return list(hits)
-
-
-def assess_external_vulns(
- *,
- probe_cache: dict[str, dict[str, Any]],
- members: list[dict[str, Any]],
- osv_cache: dict[str, list] | None = None,
-) -> dict[str, Any]:
- """Scan outside-visible Server headers from probe cache; critical/high → box colour."""
- if not CVE_PROBES_ENABLED:
- return {"enabled": False, "hits": [], "worst": "", "light": None, "servers": []}
- seen_srv: dict[str, str] = {}
- # Prefer apex/config responses already in cache
- for url, raw in (probe_cache or {}).items():
- srv = str((raw or {}).get("server") or "").strip()
- if not srv:
- continue
- host = _host_only(url)
- if host and host not in seen_srv:
- seen_srv[host] = srv
- # Also ensure we looked at each member host apex once
- for m in members or []:
- host = _host_only(str(m.get("host") or ""))
- if not host or host.upper() == "IBAN" or host in seen_srv:
- continue
- url = f"https://{host}/"
- if url not in probe_cache:
- probe_cache[url] = probe_http(url)
- srv = str((probe_cache[url] or {}).get("server") or "").strip()
- if srv:
- seen_srv[host] = srv
-
- servers_out = []
- all_hits: list[dict[str, Any]] = []
- osv_cache = osv_cache if osv_cache is not None else {}
- for host, srv in sorted(seen_srv.items()):
- pkg = guess_http_package(srv)
- ver = extract_http_version(srv)
- entry = {"host": host, "server": srv, "package": pkg, "version": ver, "hits": []}
- if pkg and ver:
- hits = osv_hits_for_server(pkg, ver, cache=osv_cache)
- for h in hits:
- h2 = {**h, "host": host, "server": srv}
- entry["hits"].append(h2)
- all_hits.append(h2)
- servers_out.append(entry)
-
- worst = ""
- light = None
- for h in all_hits:
- sev = h.get("severity") or ""
- if _severity_rank(sev) > _severity_rank(worst):
- worst = sev
- if worst == "CRITICAL":
- light = "red"
- elif worst == "HIGH":
- light = "orange"
- elif worst == "MEDIUM":
- light = "yellow"
- return {
- "enabled": True,
- "hits": all_hits[:20],
- "worst": worst,
- "light": light,
- "servers": servers_out,
- }
-
-
-def apply_vuln_to_stack(
- light: str,
- intensity: str,
- members: list[dict[str, Any]],
- vulns: dict[str, Any],
-) -> tuple[str, str, list[dict[str, Any]]]:
- """Raise stack colour for critical/high outside-visible CVEs; add quiet member row."""
- vlight = vulns.get("light")
- hits = vulns.get("hits") or []
- if not vlight or not hits:
- return light, intensity, members
- # synthetic member so UI/aggregation stay consistent
- top = hits[0]
- reason = f"{top.get('id')} {top.get('severity')} ({top.get('package')}/{top.get('version')})"
- members = list(members) + [
- {
- "role": "cve",
- "host": top.get("host") or "edge",
- "ok": False,
- "light": vlight,
- "reason": reason,
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": {"n": 0, "ok": 0, "soft": 0, "bad": 0, "skipped": 0, "light": "green", "dots": []},
- }
- ]
- # re-aggregate including CVE member
- light2, intensity2 = aggregate_stack(members)
- return light2, intensity2, members
-
-
-def classify_probe(
- result: dict[str, Any],
- *,
- prev: dict[str, Any] | None = None,
- now: float | None = None,
- acute_s: float = ACUTE_DOWN_S,
- soft_s: float = 2.5,
-) -> dict[str, Any]:
- """Map probe result + previous down_since → light/reason."""
- now = time.time() if now is None else now
- prev = prev or {}
- ok = bool(result.get("ok"))
- code = int(result.get("code") or 0)
- elapsed = float(result.get("elapsed") or 0)
- error = result.get("error")
- down_since = prev.get("down_since")
-
- if ok:
- down_since = None
- if elapsed >= soft_s:
- light, reason = "yellow", "slow"
- else:
- light, reason = "green", "ok"
- else:
- # Reachable but bad status → degraded rather than offline
- if code >= 400:
- down_since = None
- if code in (401, 403):
- light, reason = "blue", "auth_present"
- elif code >= 500:
- light, reason = "orange", f"http_{code}"
- else:
- light, reason = "yellow", f"http_{code}"
- else:
- prev_ok = prev.get("ok")
- try:
- inherited = float(prev["down_since"]) if prev.get("down_since") is not None else None
- except (TypeError, ValueError):
- inherited = None
- if prev_ok or inherited is None:
- down_since = now
- else:
- down_since = inherited
- age = now - float(down_since)
- err_l = str(error or "").lower()
- # Timeouts / reset often mean DoS or broken path — orange if still "fresh"
- if age < acute_s and ("timeout" in err_l or "timedout" in err_l or "reset" in err_l):
- light, reason = "orange", "hard_degraded"
- elif age < acute_s:
- light, reason = "red", "down_acute"
- else:
- light, reason = "black", "down_long"
-
- return {
- "ok": ok,
- "light": light,
- "reason": reason,
- "down_since": down_since,
- "code": code,
- "elapsed": round(elapsed, 3),
- "error": error,
- "url": result.get("url"),
- "ts": now,
- }
-
-
-def _cap_light(light: str, max_light: str) -> str:
- if SEVERITY.get(light, 5) > SEVERITY.get(max_light, 5):
- return max_light
- return light
-
-
-def summarize_endpoints(endpoints: list[dict[str, Any]]) -> dict[str, Any]:
- """Compact rollup for UI: counts + worst light (dots = probed only, not skips)."""
- if not endpoints:
- return {"n": 0, "ok": 0, "soft": 0, "bad": 0, "skipped": 0, "light": "green", "dots": []}
- dots: list[str] = []
- ok = soft = bad = skipped = 0
- worst = "green"
- for ep in endpoints:
- if ep.get("skipped"):
- skipped += 1
- continue
- L = ep.get("light") or "black"
- dots.append(L)
- if SEVERITY.get(L, 5) > SEVERITY.get(worst, 0):
- worst = L
- if SEVERITY.get(L, 5) <= SEVERITY["blue"]:
- ok += 1
- elif SEVERITY.get(L, 5) <= SEVERITY["yellow"]:
- soft += 1
- else:
- bad += 1
- return {
- "n": len(endpoints),
- "ok": ok,
- "soft": soft,
- "bad": bad,
- "skipped": skipped,
- "light": worst if dots else "green",
- "dots": dots,
- }
-
-
-def classify_endpoint_probe(
- raw: dict[str, Any],
- *,
- expect: tuple[int, ...],
- severity: str = "soft",
- hard: bool | None = None,
- prev: dict[str, Any] | None = None,
- now: float | None = None,
- acute_s: float = ACUTE_DOWN_S,
-) -> dict[str, Any]:
- """Map HTTP probe to light.
-
- soft → fail capped at yellow
- medium → missing/wrong is at least orange (mittelschwer), not red for 4xx
- hard → full classify_probe severity
- """
- if hard is not None and not severity:
- severity = "hard" if hard else "soft"
- sev = (severity or "soft").lower()
- code = int(raw.get("code") or 0)
- if code in expect:
- c = classify_probe(raw, prev=prev, now=now, acute_s=acute_s)
- return {**c, "expected": True, "severity": sev}
- fake = {**raw, "ok": False}
- if code >= 400:
- fake["error"] = f"http_{code}"
- elif not fake.get("error"):
- fake["error"] = "unreachable"
- c = classify_probe(fake, prev=prev, now=now, acute_s=acute_s)
- reason = c.get("reason") or (f"http_{code}" if code else "unreachable")
- if sev == "soft":
- c = {
- **c,
- "light": _cap_light(c.get("light") or "yellow", ENDPOINT_SOFT_MAX),
- "reason": reason,
- }
- elif sev == "medium":
- # At least orange; keep 5xx/unreachable from going black via acute age — still medium.
- light = c.get("light") or "orange"
- if SEVERITY.get(light, 0) < SEVERITY["orange"]:
- light = "orange"
- elif SEVERITY.get(light, 0) > SEVERITY["orange"]:
- light = "orange"
- c = {**c, "light": light, "reason": reason, "down_since": None}
- # hard: keep classify_probe result
- return {**c, "expected": False, "severity": sev}
-
-
-def probe_role_endpoints(
- role: str,
- host: str,
- *,
- has_intro: bool = False,
- landing: bool | None = None,
- legal_severity: str = "soft",
- probe_fn: Callable[[str], dict[str, Any]],
- cache: dict[str, dict[str, Any]] | None = None,
- prev_by_path: dict[str, dict[str, Any]] | None = None,
- now: float | None = None,
- acute_s: float = ACUTE_DOWN_S,
-) -> list[dict[str, Any]]:
- """Probe suite-like public endpoints for one machine; return compact rows."""
- host = _host_only(host)
- if not host or host.upper() == "IBAN":
- return []
- # Compat: older callers passed landing=
- if landing is not None:
- has_intro = bool(landing)
- catalog = ROLE_ENDPOINTS.get(role) or []
- cache = cache if cache is not None else {}
- prev_by_path = prev_by_path or {}
- now = time.time() if now is None else now
- out: list[dict[str, Any]] = []
- for spec in catalog:
- if spec.get("need_intro") and not has_intro:
- out.append(
- {
- "id": spec["id"],
- "path": spec["path"],
- "skipped": True,
- "light": "blue",
- "reason": "no_intro",
- "code": 0,
- "elapsed": 0.0,
- }
- )
- continue
- url = f"https://{host}{spec['path']}"
- if url in cache:
- raw = cache[url]
- else:
- raw = probe_fn(url)
- cache[url] = raw
- sev = str(spec.get("severity") or ("hard" if spec.get("hard") else "soft"))
- if spec.get("legal"):
- sev = legal_severity or sev
- classified = classify_endpoint_probe(
- raw,
- expect=tuple(spec.get("expect") or (200,)),
- severity=sev,
- prev=prev_by_path.get(spec["path"]),
- now=now,
- acute_s=acute_s,
- )
- out.append(
- {
- "id": spec["id"],
- "path": spec["path"],
- "url": url,
- "skipped": False,
- "light": classified.get("light") or "black",
- "reason": classified.get("reason") or "",
- "code": classified.get("code") or 0,
- "elapsed": classified.get("elapsed") or 0.0,
- "ok": bool(classified.get("ok")) and bool(classified.get("expected")),
- "severity": sev,
- # medium/hard pull machine colour past soft yellow
- "hard": sev in ("hard", "medium"),
- }
- )
- return out
-
-
-def merge_member_endpoint_light(member: dict[str, Any], endpoints: list[dict[str, Any]]) -> dict[str, Any]:
- """Attach endpoints + summary; raise member light to worst *hard* endpoint issue."""
- summary = summarize_endpoints(endpoints)
- member = {**member, "endpoints": endpoints, "endpoints_summary": summary}
- # Informational members stay as-is
- if (member.get("reason") or "") in _AGG_SKIP_REASONS:
- return member
- base = member.get("light") or "black"
- # Only hard endpoint failures pull the machine light past yellow via soft cap already applied
- worst = base
- for ep in endpoints:
- if ep.get("skipped"):
- continue
- L = ep.get("light") or "black"
- if ep.get("hard") is False and SEVERITY.get(L, 0) <= SEVERITY["yellow"]:
- # soft noise shouldn't turn machine red if primary is green — still show yellow if soft warn
- if SEVERITY.get(L, 0) > SEVERITY.get(worst, 0) and SEVERITY.get(L, 0) <= SEVERITY["yellow"]:
- worst = L
- continue
- if SEVERITY.get(L, 0) > SEVERITY.get(worst, 0):
- worst = L
- member["light"] = worst
- return member
-
-
-def score_light_from_ratio(ratio: float, *, all_black: bool = False) -> str:
- """Map green/total ratio to overall traffic light (incl. white = gone)."""
- if ratio <= 0:
- return "black" if all_black else "white"
- if ratio >= SCORE_RATIO_GREEN:
- return "green"
- if ratio >= SCORE_RATIO_BLUE:
- return "blue"
- if ratio >= SCORE_RATIO_YELLOW:
- return "yellow"
- if ratio >= SCORE_RATIO_ORANGE:
- return "orange"
- if ratio >= SCORE_RATIO_RED:
- return "red"
- return "white"
-
-
-def compute_total_score(
- stacks: list[dict[str, Any]],
- services: list[dict[str, Any]] | None = None,
- mail: list[dict[str, Any]] | None = None,
-) -> dict[str, Any]:
- """Page-level score: how many boxes are green out of all scored boxes.
-
- One point = one stack / service / mail card. Strict green only (blue ≠ green).
- """
- boxes: list[dict[str, Any]] = []
- for group in (stacks or [], services or [], mail or []):
- boxes.extend(group)
- total = len(boxes)
- by_light: dict[str, int] = {}
- for b in boxes:
- Lgt = str(b.get("light") or "black")
- by_light[Lgt] = by_light.get(Lgt, 0) + 1
- green = int(by_light.get("green") or 0)
- ratio = (green / total) if total else 0.0
- all_black = total > 0 and int(by_light.get("black") or 0) == total
- light = "white" if total == 0 else score_light_from_ratio(ratio, all_black=all_black)
- return {
- "green": green,
- "total": total,
- "ratio": round(ratio, 4),
- "pct": int(round(ratio * 100)) if total else 0,
- "light": light,
- "by_light": by_light,
- "thresholds": {
- "green": SCORE_RATIO_GREEN,
- "blue": SCORE_RATIO_BLUE,
- "yellow": SCORE_RATIO_YELLOW,
- "orange": SCORE_RATIO_ORANGE,
- "red": SCORE_RATIO_RED,
- },
- }
-
-
-def aggregate_breakdown(members: list[dict[str, Any]]) -> dict[str, Any]:
- """Counts + parts so UI can explain e.g. 4× OK, 3× Soft → Degraded."""
- counted = [m for m in members if (m.get("reason") or "") not in _AGG_SKIP_REASONS]
- if not counted:
- counted = list(members)
- by_light: dict[str, int] = {}
- parts: list[dict[str, Any]] = []
- for m in counted:
- Lgt = str(m.get("light") or "black")
- by_light[Lgt] = by_light.get(Lgt, 0) + 1
- parts.append(
- {
- "role": m.get("role"),
- "host": m.get("host"),
- "light": Lgt,
- "reason": m.get("reason"),
- }
- )
- worst = "black"
- if by_light:
- worst = LIGHTS[max(SEVERITY.get(k, 5) for k in by_light)]
- return {
- "by_light": by_light,
- "worst": worst,
- "counted": len(counted),
- "parts": parts,
- }
-
-
-def aggregate_stack(members: list[dict[str, Any]]) -> tuple[str, str]:
- """Return (light, intensity) for a stack box.
-
- Logical color = weakest *expected* member. Intensity muted unless every
- counted member is at worst severity and that severity is red/black (full).
- Informational rows (e.g. ebics_no_bank) are shown but ignored for the box.
- """
- counted = [m for m in members if (m.get("reason") or "") not in _AGG_SKIP_REASONS]
- if not counted:
- counted = list(members)
- if not counted:
- return "black", "full"
- lights = [m.get("light") or "black" for m in counted]
- worst_rank = max(SEVERITY.get(L, 5) for L in lights)
- worst = LIGHTS[worst_rank]
- n = len(counted)
- k_worst = sum(1 for L in lights if SEVERITY.get(L, 5) == worst_rank)
- # green/blue = healthy or expected-present (e.g. static_pending / no_prod_yet)
- if worst_rank <= SEVERITY["blue"]:
- return worst, "ok"
- if worst_rank >= SEVERITY["red"] and k_worst == n:
- return worst, "full"
- return worst, "muted"
-
-
-def _dig_lines(rrtype: str, name: str) -> list[str]:
- """Best-effort dig +short; empty if dig missing/fails."""
- try:
- r = subprocess.run(
- ["dig", "+short", "+time=2", "+tries=1", rrtype, name],
- capture_output=True,
- timeout=6,
- check=False,
- )
- except (OSError, subprocess.TimeoutExpired):
- return []
- out = (r.stdout or b"").decode("utf-8", "replace")
- return [ln.strip().rstrip(".") for ln in out.splitlines() if ln.strip()]
-
-
-def dns_a_aaaa(host: str) -> dict[str, list[str]]:
- h = _host_only(host)
- a = [x for x in _dig_lines("A", h) if re.match(r"^\d+\.\d+\.\d+\.\d+$", x)]
- aaaa = [x for x in _dig_lines("AAAA", h) if ":" in x]
- return {"a": a, "aaaa": aaaa}
-
-
-def dns_mx(domain: str) -> list[str]:
- lines = _dig_lines("MX", domain)
- hosts: list[str] = []
- for ln in lines:
- parts = ln.split()
- if len(parts) >= 2:
- hosts.append(parts[-1].lower().rstrip("."))
- elif parts:
- hosts.append(parts[0].lower().rstrip("."))
- return hosts
-
-
-def dns_txt(name: str) -> str:
- return " ".join(_dig_lines("TXT", name)).replace('"', "")
-
-
-def dns_ptr(ip: str) -> list[str]:
- """Reverse DNS via dig -x."""
- try:
- r = subprocess.run(
- ["dig", "+short", "+time=2", "+tries=1", "-x", ip],
- capture_output=True,
- timeout=6,
- check=False,
- )
- except (OSError, subprocess.TimeoutExpired):
- return []
- out = (r.stdout or b"").decode("utf-8", "replace")
- return [ln.strip().rstrip(".") for ln in out.splitlines() if ln.strip()]
-
-
-def probe_tls_cert(host: str, *, timeout: float = PROBE_TIMEOUT_S) -> dict[str, Any]:
- """Fetch peer cert notAfter + SANs (outside-in)."""
- h = _host_only(host)
- if not h or h.upper() == "IBAN":
- return {"ok": False, "host": h, "error": "no_host"}
- ctx = ssl.create_default_context()
- if not TLS_VERIFY:
- ctx.check_hostname = False
- ctx.verify_mode = ssl.CERT_NONE
- try:
- with socket.create_connection((h, 443), timeout=timeout) as sock:
- with ctx.wrap_socket(sock, server_hostname=h) as ssock:
- cert = ssock.getpeercert()
- except OSError as e:
- return {"ok": False, "host": h, "error": type(e).__name__}
- if not cert:
- # CERT_NONE often yields empty dict — retry binary DER via getpeercert(True)
- try:
- with socket.create_connection((h, 443), timeout=timeout) as sock:
- with ctx.wrap_socket(sock, server_hostname=h) as ssock:
- der = ssock.getpeercert(binary_form=True)
- if not der:
- return {"ok": False, "host": h, "error": "no_cert"}
- # Parse notAfter via openssl if available
- return _parse_der_cert(h, der)
- except OSError as e:
- return {"ok": False, "host": h, "error": type(e).__name__}
- return _cert_from_dict(h, cert)
-
-
-def _cert_from_dict(host: str, cert: dict[str, Any]) -> dict[str, Any]:
- not_after = cert.get("notAfter") or ""
- days_left = None
- try:
- # OpenSSL ASN.1: 'Jun 15 12:00:00 2027 GMT'
- exp = time.strptime(str(not_after), "%b %d %H:%M:%S %Y %Z")
- days_left = int((time.mktime(exp) - time.time()) / 86400)
- except (TypeError, ValueError, OverflowError, OSError):
- days_left = None
- sans: list[str] = []
- for typ, val in cert.get("subjectAltName") or []:
- if typ.lower() == "dns" and val and val not in sans:
- sans.append(str(val).lower())
- light = "green"
- reason = "cert_ok"
- if days_left is None:
- light, reason = "yellow", "cert_parse"
- elif days_left < 0:
- light, reason = "red", "cert_expired"
- elif days_left < CERT_CRIT_DAYS:
- light, reason = "orange", "cert_expiring"
- elif days_left < CERT_WARN_DAYS:
- light, reason = "yellow", "cert_warn"
- return {
- "ok": days_left is not None and days_left >= 0,
- "host": host,
- "not_after": not_after,
- "days_left": days_left,
- "sans": sans[:32],
- "light": light,
- "reason": reason,
- "error": None,
- }
-
-
-def _parse_der_cert(host: str, der: bytes) -> dict[str, Any]:
- """Fallback: openssl x509 -noout -dates -ext subjectAltName."""
- try:
- r = subprocess.run(
- ["openssl", "x509", "-inform", "DER", "-noout", "-enddate", "-ext", "subjectAltName"],
- input=der,
- capture_output=True,
- timeout=6,
- check=False,
- )
- except (OSError, subprocess.TimeoutExpired):
- return {"ok": False, "host": host, "error": "openssl_fail"}
- text = (r.stdout or b"").decode("utf-8", "replace")
- not_after = ""
- for ln in text.splitlines():
- if ln.startswith("notAfter="):
- not_after = ln.split("=", 1)[1].strip()
- sans: list[str] = []
- for m in re.finditer(r"DNS:([A-Za-z0-9_.*-]+)", text):
- v = m.group(1).lower()
- if v not in sans:
- sans.append(v)
- fake = {"notAfter": not_after, "subjectAltName": [("DNS", s) for s in sans]}
- out = _cert_from_dict(host, fake)
- if out.get("days_left") is None and not_after:
- out["error"] = "cert_parse"
- return out
-
-
-def parse_services_conf(path: Path | None = None) -> list[dict[str, Any]]:
- """Company/service hosts (non-Taler): id host label zone."""
- path = path or default_services_conf()
- rows: list[dict[str, Any]] = []
- if not path.is_file():
- return rows
- seen: set[str] = set()
- for raw in path.read_text(encoding="utf-8").splitlines():
- line = raw.split("#", 1)[0].strip()
- if not line:
- continue
- parts = line.split()
- if len(parts) < 2:
- continue
- sid, host = parts[0], _host_only(parts[1])
- label = parts[2] if len(parts) > 2 else "service"
- zone = parts[3] if len(parts) > 3 else "public"
- if not host or host in seen:
- continue
- seen.add(host)
- rows.append(
- {
- "id": sid,
- "host": host,
- "service_label": label,
- "zone": zone,
- "kind": "service",
- }
- )
- return rows
-
-
-def parse_mail_catalog(path: Path | None = None) -> list[dict[str, Any]]:
- path = path or default_mail_catalog()
- rows: list[dict[str, Any]] = []
- if not path.is_file():
- return rows
- for raw in path.read_text(encoding="utf-8").splitlines():
- line = raw.split("#", 1)[0].strip()
- if not line:
- continue
- parts = line.split()
- if len(parts) < 2:
- continue
- domain = parts[0]
- expect_mx = [x.strip().lower() for x in parts[1].split(",") if x.strip()]
- mail_hosts = (
- [x.strip().lower() for x in parts[2].split(",") if x.strip()]
- if len(parts) > 2
- else list(expect_mx)
- )
- smtp_ports = [
- p.strip()
- for p in (parts[3] if len(parts) > 3 else "25,465,587").split(",")
- if p.strip()
- ]
- imap_ports = [
- p.strip()
- for p in (parts[4] if len(parts) > 4 else "143,993").split(",")
- if p.strip()
- ]
- label = parts[5] if len(parts) > 5 else domain
- rows.append(
- {
- "domain": domain,
- "expect_mx": expect_mx,
- "mail_hosts": mail_hosts,
- "smtp_ports": smtp_ports,
- "imap_ports": imap_ports,
- "label": label,
- }
- )
- return rows
-
-
-def tcp_port_open(host: str, port: int | str, *, timeout_s: float = MAIL_PORT_TIMEOUT_S) -> bool:
- try:
- with socket.create_connection((_host_only(host), int(port)), timeout=max(1.0, float(timeout_s))):
- return True
- except (OSError, ValueError, TypeError):
- return False
-
-
-def git_probe_state_path() -> Path:
- return monitord_state_dir() / "git-probes.json"
-
-
-def deliverability_state_path() -> Path:
- return monitord_state_dir() / "mail-deliverability.json"
-
-
-def load_deliverability_state() -> dict[str, Any]:
- path = deliverability_state_path()
- if not path.is_file():
- return {"schema": 1, "domains": {}}
- try:
- doc = json.loads(path.read_text(encoding="utf-8"))
- if isinstance(doc, dict):
- doc.setdefault("domains", {})
- return doc
- except (OSError, json.JSONDecodeError):
- pass
- return {"schema": 1, "domains": {}}
-
-
-def save_deliverability_state(doc: dict[str, Any]) -> None:
- path = deliverability_state_path()
- atomic_write_json = globals().get("atomic_write_json")
- # atomic_write_json is defined later; fall back to simple write if importing early
- try:
- from typing import cast
-
- _ = cast(Any, None)
- except Exception:
- pass
- path.parent.mkdir(parents=True, exist_ok=True)
- tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
- text = json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
- try:
- tmp.write_text(text, encoding="utf-8")
- os.replace(tmp, path)
- finally:
- if tmp.exists():
- try:
- tmp.unlink()
- except OSError:
- pass
-
-
-def _smtp_read_reply(sock: socket.socket, timeout_s: float) -> tuple[int, str]:
- sock.settimeout(timeout_s)
- data = b""
- while True:
- chunk = sock.recv(4096)
- if not chunk:
- break
- data += chunk
- text = data.decode("utf-8", "replace")
- lines = text.splitlines()
- if not lines:
- continue
- # multi-line: last line is "NNN " (space) not "NNN-"
- last = lines[-1]
- if len(last) >= 4 and last[0:3].isdigit() and last[3] == " ":
- return int(last[0:3]), text.strip()
- if len(data) > 65536:
- break
- text = data.decode("utf-8", "replace").strip()
- code = int(text[:3]) if len(text) >= 3 and text[:3].isdigit() else 0
- return code, text
-
-
-def smtp_deliverability_probe(
- mx_host: str,
- *,
- domain: str,
- mail_from: str | None = None,
- rcpt_to: str | None = None,
- timeout_s: float = DELIVER_TIMEOUT_S,
-) -> dict[str, Any]:
- """Outside-in SMTP deliverability smoke: banner → EHLO → MAIL FROM → RCPT TO → RSET/QUIT.
-
- Does not send DATA/body. Acceptance of RCPT for postmaster@domain (or configured
- recipient) counts as deliverable. Soft-fail/greylist may still return 4xx.
- """
- h = _host_only(mx_host)
- mail_from = mail_from or os.environ.get("TALER_MONITORD_DELIVER_FROM", "monitor@taler-monitoring.invalid")
- rcpt_to = rcpt_to or os.environ.get("TALER_MONITORD_DELIVER_RCPT") or f"postmaster@{domain}"
- t0 = time.monotonic()
- steps: list[str] = []
- try:
- with socket.create_connection((h, 25), timeout=max(2.0, float(timeout_s))) as sock:
- code, text = _smtp_read_reply(sock, timeout_s)
- steps.append(f"banner:{code}")
- if code != 220:
- return {
- "ok": False,
- "light": "orange",
- "reason": f"bad_banner:{code}",
- "detail": text[:160],
- "mx": h,
- "rcpt": rcpt_to,
- "elapsed": round(time.monotonic() - t0, 3),
- "steps": steps,
- }
- sock.sendall(b"EHLO taler-monitoring.invalid\r\n")
- code, text = _smtp_read_reply(sock, timeout_s)
- steps.append(f"ehlo:{code}")
- if code != 250:
- return {
- "ok": False,
- "light": "orange",
- "reason": f"ehlo_fail:{code}",
- "detail": text[:160],
- "mx": h,
- "rcpt": rcpt_to,
- "elapsed": round(time.monotonic() - t0, 3),
- "steps": steps,
- }
- sock.sendall(f"MAIL FROM:<{mail_from}>\r\n".encode())
- code, text = _smtp_read_reply(sock, timeout_s)
- steps.append(f"mail:{code}")
- if code not in (250, 251):
- return {
- "ok": False,
- "light": "orange",
- "reason": f"mail_from_reject:{code}",
- "detail": text[:160],
- "mx": h,
- "rcpt": rcpt_to,
- "elapsed": round(time.monotonic() - t0, 3),
- "steps": steps,
- }
- sock.sendall(f"RCPT TO:<{rcpt_to}>\r\n".encode())
- code, text = _smtp_read_reply(sock, timeout_s)
- steps.append(f"rcpt:{code}")
- try:
- sock.sendall(b"RSET\r\n")
- _smtp_read_reply(sock, min(3.0, timeout_s))
- sock.sendall(b"QUIT\r\n")
- except OSError:
- pass
- elapsed = round(time.monotonic() - t0, 3)
- if code in (250, 251):
- return {
- "ok": True,
- "light": "green",
- "reason": "rcpt_accepted",
- "detail": text[:160],
- "mx": h,
- "rcpt": rcpt_to,
- "elapsed": elapsed,
- "steps": steps,
- }
- if 400 <= code < 500:
- # greylist / temp fail — soft degrade
- return {
- "ok": False,
- "light": "yellow",
- "reason": f"rcpt_tempfail:{code}",
- "detail": text[:160],
- "mx": h,
- "rcpt": rcpt_to,
- "elapsed": elapsed,
- "steps": steps,
- }
- return {
- "ok": False,
- "light": "orange",
- "reason": f"rcpt_reject:{code}",
- "detail": text[:160],
- "mx": h,
- "rcpt": rcpt_to,
- "elapsed": elapsed,
- "steps": steps,
- }
- except (socket.timeout, TimeoutError):
- return {
- "ok": False,
- "light": "orange",
- "reason": "smtp_timeout",
- "detail": "",
- "mx": h,
- "rcpt": rcpt_to,
- "elapsed": round(time.monotonic() - t0, 3),
- "steps": steps,
- }
- except OSError as e:
- return {
- "ok": False,
- "light": "red" if "refused" in str(e).lower() else "orange",
- "reason": f"smtp_connect:{type(e).__name__}",
- "detail": str(e)[:120],
- "mx": h,
- "rcpt": rcpt_to,
- "elapsed": round(time.monotonic() - t0, 3),
- "steps": steps,
- }
-
-
-def ensure_deliverability(
- domain: str,
- *,
- mx_hosts: list[str],
- now: float | None = None,
- force: bool = False,
- state: dict[str, Any] | None = None,
-) -> dict[str, Any]:
- """Return deliverability status for domain; refresh on interval if due.
-
- Missing/stale results → yellow (no recent test). Send/server failures → orange/red.
- """
- now = time.time() if now is None else now
- if not DELIVER_ENABLED:
- return {
- "ok": True,
- "light": "blue",
- "reason": "deliver_disabled",
- "ts": None,
- "age_s": None,
- "stale": False,
- }
- st = state if state is not None else load_deliverability_state()
- domains = st.setdefault("domains", {})
- prev = domains.get(domain) if isinstance(domains.get(domain), dict) else {}
- prev_ts = prev.get("ts")
- try:
- age = None if prev_ts is None else now - float(prev_ts)
- except (TypeError, ValueError):
- age = None
- due = force or prev_ts is None or age is None or age >= DELIVER_INTERVAL_S
- if due and mx_hosts:
- mx = _host_only(mx_hosts[0])
- result = smtp_deliverability_probe(mx, domain=domain)
- result["ts"] = now
- result["domain"] = domain
- domains[domain] = result
- st["ts"] = now
- try:
- save_deliverability_state(st)
- except OSError:
- pass
- prev = result
- age = 0.0
- elif not prev:
- # never ran and no MX to probe
- return {
- "ok": False,
- "light": "yellow",
- "reason": "deliver_never_run" if mx_hosts else "deliver_no_mx",
- "ts": None,
- "age_s": None,
- "stale": True,
- "mx": "",
- "rcpt": f"postmaster@{domain}",
- }
- # Classify freshness
- try:
- age_s = None if prev.get("ts") is None else now - float(prev["ts"])
- except (TypeError, ValueError):
- age_s = None
- stale = age_s is None or age_s > DELIVER_STALE_S
- light = str(prev.get("light") or "yellow")
- reason = str(prev.get("reason") or "deliver_unknown")
- ok = bool(prev.get("ok"))
- if stale and ok:
- light, reason, ok = "yellow", "deliver_stale", False
- elif stale and not ok:
- # keep failure colour but mark stale
- if light in ("green", "blue"):
- light = "yellow"
- reason = f"deliver_stale:{reason}"
- return {
- "ok": ok and not stale,
- "light": light,
- "reason": reason,
- "ts": prev.get("ts"),
- "age_s": None if age_s is None else round(age_s, 1),
- "stale": stale,
- "mx": prev.get("mx") or "",
- "rcpt": prev.get("rcpt") or "",
- "detail": prev.get("detail") or "",
- "elapsed": prev.get("elapsed"),
- "steps": prev.get("steps") or [],
- }
-
-
-def assess_mail_domain(
- row: dict[str, Any],
- *,
- port_done: set[str] | None = None,
- deliver_state: dict[str, Any] | None = None,
- now: float | None = None,
-) -> dict[str, Any]:
- """Outside-in MX/SPF/DMARC/A/AAAA/rDNS (+ ports + deliverability) for one catalog domain."""
- domain = row["domain"]
- members: list[dict[str, Any]] = []
- port_done = port_done if port_done is not None else set()
- now = time.time() if now is None else now
- mx = dns_mx(domain)
- expect = row.get("expect_mx") or []
- mx_ok = bool(mx)
- mx_match = True
- if expect and mx:
- mx_match = any(
- any(m == w or m.endswith("." + w) or w in m for m in mx) for w in expect
- )
- elif expect and not mx:
- mx_match = False
- members.append(
- {
- "role": "mx",
- "host": ",".join(mx[:3]) or domain,
- "ok": mx_ok and mx_match,
- "light": "green" if (mx_ok and mx_match) else ("orange" if mx_ok else "red"),
- "reason": "mx_ok" if (mx_ok and mx_match) else ("mx_mismatch" if mx_ok else "mx_missing"),
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- spf = dns_txt(domain)
- has_spf = "v=spf1" in spf.lower()
- members.append(
- {
- "role": "spf",
- "host": domain,
- "ok": has_spf,
- "light": "green" if has_spf else "yellow",
- "reason": "spf_ok" if has_spf else "spf_missing",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- dmarc = dns_txt(f"_dmarc.{domain}")
- has_dmarc = "v=dmarc1" in dmarc.lower()
- members.append(
- {
- "role": "dmarc",
- "host": f"_dmarc.{domain}",
- "ok": has_dmarc,
- "light": "green" if has_dmarc else "yellow",
- "reason": "dmarc_ok" if has_dmarc else "dmarc_missing",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- # Per mail host: A/AAAA + PTR
- for h in row.get("mail_hosts") or []:
- addrs = dns_a_aaaa(h)
- a, aaaa = addrs.get("a") or [], addrs.get("aaaa") or []
- if not a and not aaaa:
- members.append(
- {
- "role": "dns",
- "host": h,
- "ok": False,
- "light": "red",
- "reason": "dns_missing",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- continue
- light = "green"
- reason = "dns_ok"
- if a and not aaaa:
- light, reason = "yellow", "no_ipv6"
- members.append(
- {
- "role": "dns",
- "host": h,
- "ok": True,
- "light": light,
- "reason": reason,
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- "a": a[:4],
- "aaaa": aaaa[:4],
- }
- )
- # rDNS on first A
- if a:
- ptrs = dns_ptr(a[0])
- ptr_ok = bool(ptrs)
- members.append(
- {
- "role": "rdns",
- "host": a[0],
- "ok": ptr_ok,
- "light": "green" if ptr_ok else "yellow",
- "reason": ("rdns:" + ",".join(ptrs[:2])) if ptr_ok else "rdns_missing",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- if MAIL_PORTS_ENABLED and h not in port_done:
- port_done.add(h)
- for p in row.get("smtp_ports") or []:
- open_ok = tcp_port_open(h, p)
- # 587 often filtered on pixel → yellow warn, not red
- if open_ok:
- light_p, reason_p = "green", f"smtp_{p}_open"
- elif str(p) == "587":
- light_p, reason_p = "yellow", f"smtp_{p}_closed"
- else:
- light_p, reason_p = "orange", f"smtp_{p}_closed"
- members.append(
- {
- "role": "smtp",
- "host": f"{h}:{p}",
- "ok": open_ok,
- "light": light_p,
- "reason": reason_p,
- "down_since": None,
- "code": int(p) if str(p).isdigit() else 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- for p in row.get("imap_ports") or []:
- open_ok = tcp_port_open(h, p)
- members.append(
- {
- "role": "imap",
- "host": f"{h}:{p}",
- "ok": open_ok,
- "light": "green" if open_ok else "orange",
- "reason": f"imap_{p}_open" if open_ok else f"imap_{p}_closed",
- "down_since": None,
- "code": int(p) if str(p).isdigit() else 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- elif MAIL_PORTS_ENABLED and h in port_done:
- members.append(
- {
- "role": "smtp",
- "host": h,
- "ok": True,
- "light": "blue",
- "reason": "ports_probed_earlier",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- # Deliverability (regular, cached) — missing/stale → yellow; send fail → orange/red
- mx_targets = list(mx) or list(row.get("mail_hosts") or [])
- deliver = ensure_deliverability(
- domain, mx_hosts=mx_targets, now=now, state=deliver_state
- )
- members.append(
- {
- "role": "deliver",
- "host": deliver.get("mx") or (mx_targets[0] if mx_targets else domain),
- "ok": bool(deliver.get("ok")),
- "light": deliver.get("light") or "yellow",
- "reason": deliver.get("reason") or "deliver_unknown",
- "down_since": None,
- "code": 0,
- "elapsed": float(deliver.get("elapsed") or 0.0),
- "error": deliver.get("detail") or None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- light, intensity = aggregate_stack(members)
- software = colorize_software(
- [
- item
- for h in (row.get("mail_hosts") or [])[:2]
- for item in smtp_banner_software(h)
- ],
- published=None,
- )
- return {
- "id": domain,
- "currency": "",
- "currency_info": None,
- "kind": "mail",
- "service_label": row.get("label") or domain,
- "commercial_prod": False,
- "bank_backend": "",
- "wire_methods": [],
- "local": False,
- "light": light,
- "intensity": intensity,
- "members": members,
- "stats": None,
- "software": software,
- "deliverability": deliver,
- "network": {"zone": "mail", "label": "mail plane", "ssh": {"expected": False}},
- "vulns": {"enabled": False, "worst": "", "light": None, "hits": [], "servers": []},
- "breakdown": aggregate_breakdown(members),
- "tls": {},
- "orphan": False,
- }
-
-
-def _run_git_ls_remote(url: str, *, timeout_s: float) -> dict[str, Any]:
- """Soft git probe via `git ls-remote` (no checkout; no leftover worktree)."""
- t0 = time.monotonic()
- env = os.environ.copy()
- env["GIT_TERMINAL_PROMPT"] = "0"
- env["GIT_SSH_COMMAND"] = (
- "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "
- "-o ConnectTimeout=5 -o IdentitiesOnly=yes"
- )
- try:
- r = subprocess.run(
- ["git", "ls-remote", url],
- capture_output=True,
- timeout=max(2.0, float(timeout_s)),
- check=False,
- env=env,
- )
- elapsed = round(time.monotonic() - t0, 3)
- out = (r.stdout or b"").decode("utf-8", "replace").strip()
- err = (r.stderr or b"").decode("utf-8", "replace").strip()[:200]
- cmd = f"git ls-remote {url}"
- if r.returncode == 0:
- return {
- "ok": True,
- "light": "green",
- "reason": "ls-remote_ok",
- "elapsed": elapsed,
- "detail": (out.splitlines()[0][:120] if out else "empty_list"),
- "command": cmd,
- }
- # Auth / no key / unreachable → soft (yellow); keep orange for hard refuse
- soft = (
- r.returncode in (2, 128)
- or "Permission denied" in err
- or "Host key" in err
- or "Could not resolve" in err
- or "unable to access" in err.lower()
- )
- return {
- "ok": False,
- "light": "yellow" if soft else "orange",
- "reason": err or f"ls-remote_rc_{r.returncode}",
- "elapsed": elapsed,
- "detail": "",
- "command": cmd,
- }
- except FileNotFoundError:
- return {
- "ok": False,
- "light": "blue",
- "reason": "git_not_installed",
- "elapsed": round(time.monotonic() - t0, 3),
- "detail": "",
- "command": f"git ls-remote {url}",
- }
- except subprocess.TimeoutExpired:
- return {
- "ok": False,
- "light": "yellow",
- "reason": "ls-remote_timeout",
- "elapsed": round(time.monotonic() - t0, 3),
- "detail": "",
- "command": f"git ls-remote {url}",
- }
-
-
-def load_git_probe_state() -> dict[str, Any]:
- path = git_probe_state_path()
- if not path.is_file():
- return {"schema": 1, "hosts": {}}
- try:
- doc = json.loads(path.read_text(encoding="utf-8"))
- if isinstance(doc, dict):
- doc.setdefault("hosts", {})
- return doc
- except (OSError, json.JSONDecodeError):
- pass
- return {"schema": 1, "hosts": {}}
-
-
-def save_git_probe_state(doc: dict[str, Any]) -> None:
- path = git_probe_state_path()
- path.parent.mkdir(parents=True, exist_ok=True)
- tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
- try:
- tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
- os.replace(tmp, path)
- finally:
- if tmp.exists():
- try:
- tmp.unlink()
- except OSError:
- pass
-
-
-def collect_git_probe(
- host: str,
- *,
- now: float | None = None,
- force: bool = False,
-) -> dict[str, Any]:
- """Infrequent HTTPS+SSH git ls-remote; cached under monitord state."""
- now = time.time() if now is None else float(now)
- host = _host_only(host)
- empty = {
- "enabled": False,
- "ok": None,
- "light": "blue",
- "reason": "git_probes_disabled",
- "https": None,
- "ssh": None,
- "age_s": None,
- "stale": False,
- }
- if not GIT_PROBE_ENABLED or not host:
- return empty
- state = load_git_probe_state()
- prev = (state.get("hosts") or {}).get(host) or {}
- prev_ts = prev.get("ts")
- try:
- age = None if prev_ts is None else now - float(prev_ts)
- except (TypeError, ValueError):
- age = None
- due = force or prev_ts is None or age is None or age >= GIT_PROBE_INTERVAL_S
- if not due and prev:
- stale = age is None or age > GIT_PROBE_STALE_S
- light = str(prev.get("light") or "green")
- if stale and light == "green":
- light = "yellow"
- return {
- "enabled": True,
- "ok": bool(prev.get("ok")),
- "light": light,
- "reason": ("git_stale:" if stale else "") + str(prev.get("reason") or ""),
- "https": prev.get("https"),
- "ssh": prev.get("ssh"),
- "age_s": age,
- "stale": stale,
- "ts": prev_ts,
- }
- https = _run_git_ls_remote(f"https://{host}/", timeout_s=GIT_PROBE_TIMEOUT_S)
- ssh = _run_git_ls_remote(f"git@{host}:", timeout_s=GIT_PROBE_TIMEOUT_S)
- # Aggregate: worst soft colour; both fail → orange max for soft probe
- lights = [str(https.get("light") or "yellow"), str(ssh.get("light") or "yellow")]
- worst = lights[0]
- for Lgt in lights[1:]:
- if SEVERITY.get(Lgt, 0) > SEVERITY.get(worst, 0):
- worst = Lgt
- if worst in ("red", "black"):
- worst = "orange"
- ok = bool(https.get("ok") or ssh.get("ok"))
- if ok and worst in ("orange", "red"):
- worst = "yellow" # one plane OK → soft at worst
- reason = "git_ok" if ok else "git_unreachable"
- if https.get("ok") and not ssh.get("ok"):
- reason = "git_https_ok_ssh_soft"
- elif ssh.get("ok") and not https.get("ok"):
- reason = "git_ssh_ok_https_soft"
- entry = {
- "ts": now,
- "ok": ok,
- "light": worst if not ok else "green",
- "reason": reason,
- "https": https,
- "ssh": ssh,
- }
- state.setdefault("hosts", {})[host] = entry
- save_git_probe_state(state)
- return {
- "enabled": True,
- "ok": ok,
- "light": entry["light"],
- "reason": reason,
- "https": https,
- "ssh": ssh,
- "age_s": 0.0,
- "stale": False,
- "ts": now,
- }
-
-
-def payability_state_path() -> Path:
- return monitord_state_dir() / "payability.json"
-
-
-def load_payability_state() -> dict[str, Any]:
- path = payability_state_path()
- if not path.is_file():
- return {"schema": 1, "domains": {}}
- try:
- doc = json.loads(path.read_text(encoding="utf-8"))
- if isinstance(doc, dict):
- doc.setdefault("domains", {})
- return doc
- except (OSError, json.JSONDecodeError):
- pass
- return {"schema": 1, "domains": {}}
-
-
-def save_payability_state(doc: dict[str, Any]) -> None:
- path = payability_state_path()
- path.parent.mkdir(parents=True, exist_ok=True)
- tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
- try:
- tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
- os.replace(tmp, path)
- finally:
- if tmp.exists():
- try:
- tmp.unlink()
- except OSError:
- pass
-
-
-def _suite_root() -> Path:
- raw = (os.environ.get("SUITE_DIR") or "").strip()
- if raw:
- return Path(raw)
- return Path(__file__).resolve().parents[1]
-
-
-def _run_zero_pay(domain: str, *, timeout_s: float) -> dict[str, Any]:
- """Run suite zero-pay COUNT=1 for domain (needs WALLET_DB / funded wallet)."""
- root = _suite_root()
- script = root / "taler-monitoring.sh"
- cmd = [
- "bash",
- str(script),
- "-d",
- domain,
- "zero-pay",
- ]
- env = os.environ.copy()
- env["COUNT"] = "1"
- env["STRICT_EXIT"] = env.get("STRICT_EXIT", "1")
- t0 = time.monotonic()
- try:
- r = subprocess.run(
- cmd,
- capture_output=True,
- timeout=max(30.0, float(timeout_s)),
- check=False,
- env=env,
- cwd=str(root),
- )
- elapsed = round(time.monotonic() - t0, 3)
- err = (r.stderr or b"").decode("utf-8", "replace").strip()[-300:]
- out = (r.stdout or b"").decode("utf-8", "replace").strip()[-300:]
- ok = r.returncode == 0
- return {
- "ok": ok,
- "light": "green" if ok else "orange",
- "reason": "zero_pay_ok" if ok else (err or out or f"zero_pay_rc_{r.returncode}"),
- "elapsed": elapsed,
- "command": f"COUNT=1 ./taler-monitoring.sh -d {domain} zero-pay",
- "rc": r.returncode,
- }
- except FileNotFoundError:
- return {
- "ok": False,
- "light": "blue",
- "reason": "suite_missing",
- "elapsed": 0.0,
- "command": f"COUNT=1 ./taler-monitoring.sh -d {domain} zero-pay",
- "rc": 127,
- }
- except subprocess.TimeoutExpired:
- return {
- "ok": False,
- "light": "orange",
- "reason": "zero_pay_timeout",
- "elapsed": round(time.monotonic() - t0, 3),
- "command": f"COUNT=1 ./taler-monitoring.sh -d {domain} zero-pay",
- "rc": 124,
- }
-
-
-def collect_payability(
- domain: str,
- *,
- now: float | None = None,
- force: bool = False,
-) -> dict[str, Any]:
- """Cached payability smoke for a stack domain (few×/day when PAY_RUN=1)."""
- now = time.time() if now is None else float(now)
- domain = str(domain or "").strip()
- empty = {
- "enabled": False,
- "ok": None,
- "light": "blue",
- "reason": "pay_disabled",
- "age_s": None,
- "stale": False,
- "command": "",
- }
- if not PAY_ENABLED or not domain:
- return empty
- allowed = set(PAY_DOMAINS)
- if domain not in allowed and not any(
- domain == d or domain.endswith("." + d) or d.endswith("." + domain)
- for d in allowed
- ):
- return {**empty, "reason": "pay_domain_skip"}
- state = load_payability_state()
- prev = (state.get("domains") or {}).get(domain) or {}
- prev_ts = prev.get("ts")
- try:
- age = None if prev_ts is None else now - float(prev_ts)
- except (TypeError, ValueError):
- age = None
- due = force or prev_ts is None or age is None or age >= PAY_INTERVAL_S
- if not due and prev:
- stale = age is None or age > PAY_STALE_S
- light = str(prev.get("light") or "green")
- if stale and light == "green":
- light = "yellow"
- return {
- "enabled": True,
- "ok": bool(prev.get("ok")),
- "light": light,
- "reason": ("pay_stale:" if stale else "") + str(prev.get("reason") or ""),
- "age_s": age,
- "stale": stale,
- "command": prev.get("command") or "",
- "ts": prev_ts,
- }
- if not PAY_RUN:
- # enabled but operator did not allow live wallet run this tick
- if prev:
- stale = age is None or age > PAY_STALE_S
- light = str(prev.get("light") or "yellow")
- if stale:
- light = "yellow"
- return {
- "enabled": True,
- "ok": bool(prev.get("ok")),
- "light": light,
- "reason": ("pay_stale:" if stale else "") + str(prev.get("reason") or "pay_cached"),
- "age_s": age,
- "stale": bool(stale),
- "command": prev.get("command") or "",
- "ts": prev_ts,
- }
- return {
- "enabled": True,
- "ok": False,
- "light": "yellow",
- "reason": "pay_never_run",
- "age_s": None,
- "stale": True,
- "command": f"COUNT=1 ./taler-monitoring.sh -d {domain} zero-pay",
- "ts": None,
- }
- result = _run_zero_pay(domain, timeout_s=PAY_TIMEOUT_S)
- entry = {
- "ts": now,
- "ok": bool(result.get("ok")),
- "light": result.get("light") or "orange",
- "reason": result.get("reason") or "",
- "command": result.get("command") or "",
- "elapsed": result.get("elapsed"),
- "rc": result.get("rc"),
- }
- state.setdefault("domains", {})[domain] = entry
- save_payability_state(state)
- return {
- "enabled": True,
- "ok": entry["ok"],
- "light": entry["light"],
- "reason": entry["reason"],
- "age_s": 0.0,
- "stale": False,
- "command": entry["command"],
- "ts": now,
- }
-
-
-def build_service_status(
- row: dict[str, Any],
- *,
- prev_hosts: dict[str, dict[str, Any]],
- now: float,
- acute_s: float,
- probe_fn: Callable[[str], dict[str, Any]],
- osv_cache: dict[str, list],
- published: dict[str, Any] | None = None,
-) -> dict[str, Any]:
- """Probe a non-Taler company/service host (apex HTTPS)."""
- host = row["host"]
- url = f"https://{host}/"
- raw = probe_fn(url)
- classified = classify_probe(raw, prev=prev_hosts.get(host), now=now, acute_s=acute_s)
- member = {
- "role": "site",
- "host": host,
- **classified,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- members = [member]
- tls: dict[str, Any] = {}
- if CERT_PROBES_ENABLED:
- tls = probe_tls_cert(host)
- if tls.get("light") and SEVERITY.get(str(tls["light"]), 0) > SEVERITY.get(
- member.get("light") or "green", 0
- ):
- members.append(
- {
- "role": "tls",
- "host": host,
- "ok": bool(tls.get("ok")),
- "light": tls.get("light") or "yellow",
- "reason": tls.get("reason") or "cert",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": tls.get("error"),
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- label = str(row.get("service_label") or "")
- git_probe: dict[str, Any] | None = None
- if label.lower() == "git" or host.startswith("git."):
- git_probe = collect_git_probe(host, now=now)
- if git_probe.get("enabled"):
- members.append(
- {
- "role": "git",
- "host": host,
- "ok": bool(git_probe.get("ok")),
- "light": git_probe.get("light") or "yellow",
- "reason": git_probe.get("reason") or "git",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- "command": ((git_probe.get("https") or {}).get("command") or ""),
- }
- )
- light, intensity = aggregate_stack(members)
- probe_cache = {url: raw}
- vulns = assess_external_vulns(
- probe_cache=probe_cache, members=members, osv_cache=osv_cache
- )
- light, intensity, members = apply_vuln_to_stack(light, intensity, members, vulns)
- software = collect_software_from_probes(
- probe_cache=probe_cache,
- members=members,
- vulns=vulns,
- smtp=False,
- published=published,
- )
- return {
- "id": row.get("id") or host,
- "currency": "",
- "currency_info": None,
- "kind": "service",
- "service_label": row.get("service_label") or "service",
- "commercial_prod": False,
- "bank_backend": "",
- "wire_methods": [],
- "local": False,
- "light": light,
- "intensity": intensity,
- "members": members,
- "stats": None,
- "software": software,
- "git": git_probe or {},
- "network": {
- "zone": row.get("zone") or "public",
- "label": row.get("zone") or "public",
- "ssh": {"expected": False},
- },
- "vulns": {
- "enabled": vulns.get("enabled"),
- "worst": vulns.get("worst") or "",
- "light": vulns.get("light"),
- "hits": [
- {
- "id": h.get("id"),
- "severity": h.get("severity"),
- "package": h.get("package"),
- "version": h.get("version"),
- "host": h.get("host"),
- "summary": h.get("summary") or "",
- }
- for h in (vulns.get("hits") or [])[:8]
- ],
- "servers": vulns.get("servers") or [],
- },
- "breakdown": aggregate_breakdown(members),
- "tls": {
- "days_left": tls.get("days_left"),
- "not_after": tls.get("not_after"),
- "sans": tls.get("sans") or [],
- "reason": tls.get("reason"),
- }
- if tls
- else {},
- "orphan": False,
- }
-
-
-def _host_covered_by_known(host: str, known_hosts: set[str]) -> bool:
- """True if host is known or a subdomain/parent of a monitored host (cert sibling)."""
- h = _host_only(host).lower()
- if not h or h in known_hosts:
- return True
- for k in known_hosts:
- k = (k or "").lower()
- if not k or k.upper() == "IBAN":
- continue
- if h.endswith("." + k) or k.endswith("." + h):
- return True
- return False
-
-
-def collect_orphan_hosts(
- *,
- known_hosts: set[str],
- tls_sans: list[str],
- probe_fn: Callable[[str], dict[str, Any]],
-) -> list[dict[str, Any]]:
- """Flag SANs that look abandoned: DNS missing (default).
-
- Skips wildcard expansions already covered as siblings of monitored hosts
- (e.g. donations.demo.taler.net when demo.taler.net is known). HTTP probe
- optional via TALER_MONITORD_ORPHAN_HTTP=1 (off by default — CDN noise).
- """
- if not ORPHAN_PROBES_ENABLED:
- return []
- known = {_host_only(x).lower() for x in known_hosts if x}
- candidates: list[str] = []
- for raw_san in tls_sans:
- san = str(raw_san or "").strip().lower()
- if not san or san.startswith("*."):
- # Wildcard SAN ≠ concrete orphan candidate
- continue
- h = _host_only(san)
- if not h or _host_covered_by_known(h, known):
- continue
- if h not in candidates:
- candidates.append(h)
- orphans: list[dict[str, Any]] = []
- for h in candidates[:25]:
- addrs = dns_a_aaaa(h)
- if not (addrs.get("a") or addrs.get("aaaa")):
- orphans.append({"host": h, "reason": "dns_missing", "light": "orange"})
- continue
- if not ORPHAN_HTTP_ENABLED:
- continue
- raw = probe_fn(f"https://{h}/")
- if not raw.get("ok") and int(raw.get("code") or 0) == 0:
- orphans.append(
- {
- "host": h,
- "reason": f"http_unreachable:{raw.get('error')}",
- "light": "yellow",
- }
- )
- return orphans
-
-
-def scan_ports_list() -> list[int]:
- raw = (os.environ.get("TALER_MONITORD_PORTSCAN_PORTS") or "").strip()
- if raw:
- out: list[int] = []
- for part in re.split(r"[\s,]+", raw):
- if not part:
- continue
- try:
- out.append(int(part))
- except ValueError:
- continue
- return sorted(set(out))
- return list(_DEFAULT_SCAN_PORTS)
-
-
-def parse_ports_whitelist(path: Path | None = None) -> list[dict[str, Any]]:
- """Whitelist lines: host_or_pattern port[,port…] [# note]
-
- Patterns: exact host, ``*.example.com``, or ``*`` (any host).
- """
- path = path or default_ports_whitelist_conf()
- rows: list[dict[str, Any]] = []
- if not path.is_file():
- return rows
- for raw in path.read_text(encoding="utf-8").splitlines():
- line = raw.split("#", 1)[0].strip()
- if not line:
- continue
- parts = line.split()
- if len(parts) < 2:
- continue
- pattern = parts[0].lower().rstrip(".")
- ports: list[int] = []
- for tok in parts[1].split(","):
- tok = tok.strip()
- if not tok:
- continue
- try:
- ports.append(int(tok))
- except ValueError:
- continue
- if ports:
- rows.append({"pattern": pattern, "ports": ports})
- return rows
-
-
-def whitelist_ports_for_host(host: str, rules: list[dict[str, Any]]) -> set[int]:
- h = _host_only(host).lower()
- allowed: set[int] = set()
- for rule in rules or []:
- pat = str(rule.get("pattern") or "").lower()
- ports = set(int(p) for p in (rule.get("ports") or []))
- if not pat or not ports:
- continue
- if pat == "*" or pat == h:
- allowed |= ports
- elif pat.startswith("*.") and (h.endswith(pat[1:]) or h == pat[2:]):
- allowed |= ports
- return allowed
-
-
-def baseline_ports_for_host(host: str, *, mail_hosts: set[str] | None = None) -> set[int]:
- """Default expected open ports before whitelist."""
- h = _host_only(host).lower()
- allowed = set(_BASELINE_WEB_PORTS)
- if mail_hosts and h in mail_hosts:
- allowed |= set(_BASELINE_MAIL_PORTS)
- return allowed
-
-
-def tcp_scan_host(
- host: str,
- ports: list[int],
- *,
- timeout_s: float = PORT_SCAN_TIMEOUT_S,
- workers: int = PORT_SCAN_WORKERS,
-) -> list[int]:
- """Return sorted list of open TCP ports (banner-less connect)."""
- h = _host_only(host)
- if not h or h.upper() == "IBAN":
- return []
- open_ports: list[int] = []
-
- def _one(p: int) -> int | None:
- return p if tcp_port_open(h, p, timeout_s=timeout_s) else None
-
- with ThreadPoolExecutor(max_workers=max(4, min(int(workers), len(ports) or 4))) as pool:
- futs = [pool.submit(_one, p) for p in ports]
- for fut in as_completed(futs):
- try:
- got = fut.result()
- except Exception:
- got = None
- if got is not None:
- open_ports.append(int(got))
- return sorted(set(open_ports))
-
-
-def assess_host_ports(
- host: str,
- *,
- ports: list[int] | None = None,
- whitelist_rules: list[dict[str, Any]] | None = None,
- mail_hosts: set[str] | None = None,
- open_ports: list[int] | None = None,
-) -> dict[str, Any]:
- """Scan (or reuse) open ports and classify expected vs suspicious."""
- h = _host_only(host)
- ports = ports if ports is not None else scan_ports_list()
- rules = whitelist_rules if whitelist_rules is not None else parse_ports_whitelist()
- opened = list(open_ports) if open_ports is not None else tcp_scan_host(h, ports)
- allowed = baseline_ports_for_host(h, mail_hosts=mail_hosts) | whitelist_ports_for_host(h, rules)
- suspicious = [p for p in opened if p not in allowed]
- expected_hit = [p for p in opened if p in allowed]
- # hard = raise card colour; soft = list only (SSH/mail/http-alt often shared).
- high = {21, 23, 135, 139, 445, 1433, 1521, 2049, 3306, 3389, 5432, 5900, 6379, 9200, 11211, 27017}
- soft_set = {22, 25, 110, 143, 465, 587, 993, 995, 8080, 8443}
- items: list[dict[str, Any]] = []
- for p in suspicious:
- if p in high:
- sev = "hard"
- pl = "red" if p in (23, 445, 3389, 27017) else "orange"
- else:
- sev, pl = "soft", "yellow"
- if p not in soft_set:
- sev, pl = "soft", "yellow"
- items.append({"port": p, "label": _PORT_LABELS.get(p, str(p)), "severity": sev, "light": pl})
- light = "green"
- for it in items:
- if SEVERITY.get(str(it["light"]), 0) > SEVERITY.get(light, 0):
- light = str(it["light"])
- return {
- "host": h,
- "scanned": list(ports),
- "open": opened,
- "allowed": sorted(allowed),
- "expected_open": expected_hit,
- "suspicious": items,
- "light": light,
- "ok": not any(it.get("severity") == "hard" for it in items),
- }
-
-
-def collect_port_scan(
- hosts: list[str],
- *,
- mail_hosts: set[str] | None = None,
- whitelist_rules: list[dict[str, Any]] | None = None,
-) -> dict[str, Any]:
- """Scan unique hosts; return page-level findings."""
- if not PORT_SCAN_ENABLED:
- return {"enabled": False, "hosts": [], "suspicious": [], "light": None}
- ports = scan_ports_list()
- rules = whitelist_rules if whitelist_rules is not None else parse_ports_whitelist()
- mail_hosts = { _host_only(x).lower() for x in (mail_hosts or set()) }
- uniq: list[str] = []
- seen: set[str] = set()
- for h in hosts:
- hh = _host_only(h).lower()
- if not hh or hh.upper() == "IBAN" or hh in seen:
- continue
- # Skip pure IPs from rdns rows unless explicitly wanted
- if re.match(r"^\d+\.\d+\.\d+\.\d+$", hh):
- continue
- seen.add(hh)
- uniq.append(hh)
- host_results: list[dict[str, Any]] = []
- # Scan hosts concurrently (each host scans ports internally)
- def _scan(h: str) -> dict[str, Any]:
- return assess_host_ports(h, ports=ports, whitelist_rules=rules, mail_hosts=mail_hosts)
-
- with ThreadPoolExecutor(max_workers=max(4, min(12, len(uniq) or 4))) as pool:
- futs = {pool.submit(_scan, h): h for h in uniq}
- for fut in as_completed(futs):
- try:
- host_results.append(fut.result())
- except Exception as e:
- host_results.append(
- {
- "host": futs[fut],
- "scanned": ports,
- "open": [],
- "allowed": [],
- "expected_open": [],
- "suspicious": [],
- "light": "yellow",
- "ok": True,
- "error": type(e).__name__,
- }
- )
- host_results.sort(key=lambda r: r.get("host") or "")
- suspicious_flat: list[dict[str, Any]] = []
- worst = "green"
- for r in host_results:
- for s in r.get("suspicious") or []:
- suspicious_flat.append(
- {
- "host": r.get("host"),
- "port": s.get("port"),
- "label": s.get("label"),
- "severity": s.get("severity") or "soft",
- "light": s.get("light") or "yellow",
- }
- )
- Lgt = r.get("light") or "green"
- if SEVERITY.get(str(Lgt), 0) > SEVERITY.get(worst, 0):
- worst = str(Lgt)
- hard = [s for s in suspicious_flat if s.get("severity") == "hard"]
- if not suspicious_flat:
- worst = "green"
- elif not hard:
- worst = "yellow"
- return {
- "enabled": True,
- "ports": ports,
- "hosts": host_results,
- "suspicious": suspicious_flat,
- "n_hosts": len(host_results),
- "n_suspicious": len(suspicious_flat),
- "n_hard": len(hard),
- "light": worst if suspicious_flat else "green",
- }
-
-
-def build_stack_status(
- stacks: list[dict[str, Any]],
- *,
- prev_doc: dict[str, Any] | None = None,
- now: float | None = None,
- acute_s: float = ACUTE_DOWN_S,
- probe_fn: Callable[[str], dict[str, Any]] | None = None,
- public_only: bool = True,
-) -> dict[str, Any]:
- """Probe all stacks and build status document."""
- now = time.time() if now is None else now
- prev_doc = prev_doc or {}
- prev_hosts: dict[str, dict[str, Any]] = {}
- for s in prev_doc.get("stacks") or []:
- for m in s.get("members") or []:
- h = m.get("host")
- if h:
- prev_hosts[h] = m
-
- probe = probe_fn or (lambda url: probe_http(url))
- out_stacks: list[dict[str, Any]] = []
- ssh_cache: dict[str, dict[str, Any]] = {}
- osv_cache: dict[str, list] = {}
- published_doc = fetch_published_taler_packages()
- # Previous endpoint rows keyed by host+path for down_since continuity
- prev_eps: dict[str, dict[str, Any]] = {}
- for s in prev_doc.get("stacks") or []:
- for m in s.get("members") or []:
- h = m.get("host") or ""
- for ep in m.get("endpoints") or []:
- p = ep.get("path")
- if h and p:
- prev_eps[f"{h}{p}"] = ep
-
- for st in stacks:
- if public_only and st.get("local"):
- continue
- members_out: list[dict[str, Any]] = []
- kind = st.get("kind") or ("taler" if st.get("currency") else "static_pending")
- has_intro = stack_has_custom_intro(st)
- legal_sev = legal_doc_severity(st)
- bank_configured = st.get("bank_configured")
- if bank_configured is None:
- bank_configured = bank_host_configured(st.get("bank") or "")
- bank_backend = "unknown"
- wire_methods: set[str] = set()
- currency_info: dict[str, Any] | None = None
- keys_body_for_denoms: bytes | str | None = None
- probe_cache: dict[str, dict[str, Any]] = {}
-
- def cached_probe(url: str) -> dict[str, Any]:
- if url not in probe_cache:
- probe_cache[url] = probe(url)
- return probe_cache[url]
-
- if kind == "static_pending":
- # Apex static site (prod Taler not up yet)
- site_host = _host_only(st["id"])
- site_url = f"https://{site_host}/"
- site_raw = cached_probe(site_url)
- site_c = classify_probe(site_raw, prev=prev_hosts.get(site_host), now=now, acute_s=acute_s)
- if site_c.get("ok"):
- site_c["light"] = "green"
- site_c["reason"] = "static_ok"
- site_member = {"role": "site", "host": site_host, **site_c}
- site_prev = {k[len(site_host) :]: v for k, v in prev_eps.items() if k.startswith(site_host)}
- site_eps = probe_role_endpoints(
- "site",
- site_host,
- has_intro=False,
- legal_severity=legal_sev,
- probe_fn=cached_probe,
- cache=probe_cache,
- prev_by_path=site_prev,
- now=now,
- acute_s=acute_s,
- )
- members_out.append(merge_member_endpoint_light(site_member, site_eps))
-
- # Probe exchange before bank so /keys payto://… can decide EBICS vs x-taler-bank.
- role_hosts: list[tuple[str, str]] = [
- ("exchange", st["exchange"]),
- ("merchant", st["merchant"]),
- ]
- if bank_configured:
- role_hosts.insert(0, ("bank", st.get("bank") or ""))
-
- classified_by_role: dict[str, dict[str, Any]] = {}
- # Always resolve exchange (+ merchant) first for wire detection, then bank.
- probe_order = ["exchange", "merchant", "bank"] if bank_configured else ["exchange", "merchant"]
- host_by_role = {r: h for r, h in role_hosts}
-
- for role in probe_order:
- if role not in host_by_role:
- continue
- host = host_by_role[role]
- if role == "bank":
- # After exchange: skip public bank.* when wire is IBAN/EBICS-only.
- bank_backend = classify_bank_backend(
- wire_methods, bank_configured=bool(bank_configured)
- )
- if bank_backend == "ebics":
- classified_by_role[role] = {
- "ok": True,
- "light": "blue",
- "reason": "ebics_iban",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "ts": now,
- # No public bank.* — show wire kind in the host column
- "host": "IBAN",
- "role": role,
- }
- continue
- if role == "exchange":
- # Prefer /keys (accounts/payto); fall back to /config for liveness.
- keys_url = f"https://{_host_only(host)}/keys"
- raw = cached_probe(keys_url)
- cfg_data: dict[str, Any] | None = None
- if not raw.get("ok"):
- alt = member_probe_url(role, host)
- raw2 = cached_probe(alt)
- if raw2.get("ok") or int(raw2.get("code") or 0) > int(raw.get("code") or 0):
- raw = raw2
- cfg_data = _json_body(raw)
- else:
- keys_body_for_denoms = raw.get("body")
- wire_methods = payto_wire_methods(keys_body_for_denoms)
- bank_backend = classify_bank_backend(
- wire_methods, bank_configured=bool(bank_configured)
- )
- # Separate /config for currency_specification (small JSON).
- cfg_raw = cached_probe(member_probe_url(role, host))
- if cfg_raw.get("ok"):
- cfg_data = _json_body(cfg_raw)
- if cfg_data:
- cs = cfg_data.get("currency_specification")
- if not isinstance(cs, dict):
- cs = {}
- currency_info = summarize_currency_spec(
- cs,
- keys_body=keys_body_for_denoms,
- fallback_currency=str(
- cfg_data.get("currency") or st.get("currency") or ""
- ),
- )
- elif role == "bank":
- url = member_probe_url(role, host)
- raw = cached_probe(url)
- if not raw.get("ok") and int(raw.get("code") or 0) in (0, 404):
- alt = f"https://{_host_only(host)}/config"
- raw2 = cached_probe(alt)
- if raw2.get("ok") or int(raw2.get("code") or 0) > int(raw.get("code") or 0):
- raw = raw2
- else:
- raw = cached_probe(member_probe_url(role, host))
-
- classified = classify_probe(raw, prev=prev_hosts.get(host), now=now, acute_s=acute_s)
- if kind == "static_pending" and not classified.get("ok"):
- # Expected: no bank/exchange/merchant prod yet — not an outage.
- classified = {
- **classified,
- "ok": True,
- "light": "blue",
- "reason": "no_prod_yet",
- "down_since": None,
- }
- classified_by_role[role] = {"role": role, "host": host, **classified}
-
- if not bank_configured:
- bank_backend = "ebics"
- classified_by_role["bank"] = {
- "role": "bank",
- "host": "IBAN",
- "ok": True,
- "light": "blue",
- "reason": "ebics_iban",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "ts": now,
- }
-
- # Stable member order: site (optional) already appended; then bank → exchange → merchant
- for role in ("bank", "exchange", "merchant"):
- if role not in classified_by_role:
- continue
- member = classified_by_role[role]
- host = member.get("host") or ""
- # EBICS placeholder — no public endpoint fan-out
- if role == "bank" and (
- bank_backend == "ebics" or (member.get("reason") or "") in _AGG_SKIP_REASONS
- ):
- members_out.append(
- {
- **member,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- continue
- if kind == "static_pending" and (member.get("reason") or "") == "no_prod_yet":
- members_out.append(
- {
- **member,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- continue
- host_prev = {k[len(host) :]: v for k, v in prev_eps.items() if host and k.startswith(host)}
- eps = probe_role_endpoints(
- role,
- host,
- has_intro=has_intro,
- legal_severity=legal_sev,
- probe_fn=cached_probe,
- cache=probe_cache,
- prev_by_path=host_prev,
- now=now,
- acute_s=acute_s,
- )
- members_out.append(merge_member_endpoint_light(member, eps))
-
- light, intensity = aggregate_stack(members_out)
- stats = fetch_stack_stats(
- st, bank_backend=bank_backend, probe_fn=cached_probe, now=now, stale_s=STATS_STALE_S
- )
- vulns = assess_external_vulns(
- probe_cache=probe_cache, members=members_out, osv_cache=osv_cache
- )
- light, intensity, members_out = apply_vuln_to_stack(light, intensity, members_out, vulns)
-
- tls: dict[str, Any] = {}
- primary_host = ""
- for role in ("merchant", "exchange", "bank"):
- m = classified_by_role.get(role) or {}
- h = str(m.get("host") or "")
- if h and h.upper() != "IBAN":
- primary_host = h
- break
- if not primary_host:
- primary_host = _host_only(str(st.get("id") or ""))
- if CERT_PROBES_ENABLED and primary_host:
- tls = probe_tls_cert(primary_host)
- if tls.get("light") and SEVERITY.get(str(tls["light"]), 0) > SEVERITY.get(light, 0):
- members_out.append(
- {
- "role": "tls",
- "host": primary_host,
- "ok": bool(tls.get("ok")),
- "light": tls.get("light") or "yellow",
- "reason": tls.get("reason") or "cert",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": tls.get("error"),
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- light, intensity = aggregate_stack(members_out)
-
- # Prefer live exchange currency; keep domains.conf as fallback label.
- detected_currency = ""
- if currency_info and currency_info.get("currency"):
- detected_currency = str(currency_info["currency"])
- elif st.get("currency"):
- detected_currency = str(st.get("currency"))
- software = collect_software_from_probes(
- probe_cache=probe_cache,
- members=members_out,
- vulns=vulns,
- smtp=False,
- published=published_doc,
- )
- pay: dict[str, Any] = {}
- zone = classify_network_zone(st)["zone"]
- pay_id = str(st.get("id") or "")
- if PAY_ENABLED and zone in ("tops-stage", "tops-prod", "goa-local", "fp-stage"):
- pay = collect_payability(pay_id, now=now)
- if pay.get("enabled") and pay.get("light") not in (None, "blue"):
- members_out.append(
- {
- "role": "payability",
- "host": pay_id,
- "ok": bool(pay.get("ok")),
- "light": pay.get("light") or "yellow",
- "reason": pay.get("reason") or "pay",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- "command": pay.get("command") or "",
- }
- )
- light, intensity = aggregate_stack(members_out)
- out_stacks.append(
- {
- "id": st["id"],
- "currency": detected_currency or (st.get("currency") or ""),
- "currency_info": currency_info,
- "kind": kind,
- "commercial_prod": stack_is_commercial_prod(st),
- "bank_backend": bank_backend,
- "wire_methods": sorted(wire_methods),
- "local": bool(st.get("local")),
- "light": light,
- "intensity": intensity,
- "members": members_out,
- "stats": stats,
- "software": software,
- "payability": pay,
- "network": build_network_status(
- st, members=members_out, ssh_cache=ssh_cache
- ),
- "vulns": {
- "enabled": vulns.get("enabled"),
- "worst": vulns.get("worst") or "",
- "light": vulns.get("light"),
- "hits": [
- {
- "id": h.get("id"),
- "severity": h.get("severity"),
- "package": h.get("package"),
- "version": h.get("version"),
- "host": h.get("host"),
- "summary": h.get("summary") or "",
- }
- for h in (vulns.get("hits") or [])[:8]
- ],
- "servers": vulns.get("servers") or [],
- },
- "breakdown": aggregate_breakdown(members_out),
- "tls": {
- "days_left": tls.get("days_left"),
- "not_after": tls.get("not_after"),
- "sans": tls.get("sans") or [],
- "reason": tls.get("reason"),
- }
- if tls
- else {},
- "orphan": False,
- }
- )
-
- out_services: list[dict[str, Any]] = []
- if SERVICES_ENABLED:
- for row in parse_services_conf():
- out_services.append(
- build_service_status(
- row,
- prev_hosts=prev_hosts,
- now=now,
- acute_s=acute_s,
- probe_fn=probe,
- osv_cache=osv_cache,
- published=published_doc,
- )
- )
-
- out_mail: list[dict[str, Any]] = []
- if MAIL_ENABLED:
- mail_port_done: set[str] = set()
- deliver_state = load_deliverability_state() if DELIVER_ENABLED else {"domains": {}}
- for row in parse_mail_catalog():
- out_mail.append(
- assess_mail_domain(
- row,
- port_done=mail_port_done,
- deliver_state=deliver_state,
- now=now,
- )
- )
-
- known_hosts: set[str] = set()
- tls_sans: list[str] = []
- scan_hosts: list[str] = []
- for group in (out_stacks, out_services):
- for st_out in group:
- for m in st_out.get("members") or []:
- h = _host_only(str(m.get("host") or ""))
- if h and h.upper() != "IBAN":
- known_hosts.add(h)
- scan_hosts.append(h)
- for san in (st_out.get("tls") or {}).get("sans") or []:
- tls_sans.append(str(san))
- known_hosts.add(_host_only(str(st_out.get("id") or "")))
- scan_hosts.append(_host_only(str(st_out.get("id") or "")))
- # Also scan env-mapped SSH targets — findings may be hidden hosts
- for st_cfg in stacks:
- if public_only and st_cfg.get("local"):
- continue
- for h in ssh_targets_for_stack(st_cfg).get("hosts") or []:
- scan_hosts.append(h)
- mail_hosts: set[str] = set()
- for row in parse_mail_catalog() if MAIL_ENABLED else []:
- for h in row.get("mail_hosts") or []:
- mail_hosts.add(_host_only(h).lower())
- scan_hosts.append(h)
- orphans = collect_orphan_hosts(
- known_hosts=known_hosts, tls_sans=tls_sans, probe_fn=probe
- )
- ports_doc = collect_port_scan(scan_hosts, mail_hosts=mail_hosts)
- # Attach per-card port slice + optional synthetic member when suspicious
- by_host = {str(r.get("host") or ""): r for r in (ports_doc.get("hosts") or [])}
- for group in (out_stacks, out_services):
- for st_out in group:
- ssh_meta = st_out.get("network") or {}
- ssh_block = ssh_meta.get("ssh") or {}
- # UI clears candidates when hide_host; recover env-mapped hosts for port attach
- hidden_ssh: set[str] = set()
- if ssh_block.get("hide_host"):
- zone = str(ssh_meta.get("zone") or "")
- env_map = {
- "goa-local": "TALER_MONITORD_SSH_GOA",
- "fp-stage": "TALER_MONITORD_SSH_FP",
- "tops-stage": "TALER_MONITORD_SSH_TOPS",
- "tops-prod": "TALER_MONITORD_SSH_TOPS_PROD",
- }
- if zone in env_map:
- hidden_ssh = {
- _host_only(x).lower()
- for x in _env_ssh_hosts(env_map[zone])
- if x
- }
- card_hosts: list[str] = []
- for m in st_out.get("members") or []:
- h = _host_only(str(m.get("host") or ""))
- if h and h.upper() != "IBAN":
- card_hosts.append(h)
- sid = _host_only(str(st_out.get("id") or ""))
- if sid:
- card_hosts.append(sid)
- card_hosts.extend(hidden_ssh)
- sus: list[dict[str, Any]] = []
- opens: list[dict[str, Any]] = []
- worst = "green"
- for h in dict.fromkeys(_host_only(x).lower() for x in card_hosts if x):
- r = by_host.get(h)
- if not r:
- continue
- hide = h in hidden_ssh
- for s in r.get("suspicious") or []:
- sus.append(
- {
- "host": "" if hide else h,
- "port": s.get("port"),
- "label": s.get("label"),
- "severity": s.get("severity") or "soft",
- "light": s.get("light") or "yellow",
- }
- )
- if r.get("open"):
- opens.append(
- {
- "host": "" if hide else h,
- "open": r.get("open"),
- "suspicious": [s.get("port") for s in (r.get("suspicious") or [])],
- }
- )
- for s in r.get("suspicious") or []:
- if s.get("severity") != "hard":
- continue
- Lgt = s.get("light") or "orange"
- if SEVERITY.get(str(Lgt), 0) > SEVERITY.get(worst, 0):
- worst = str(Lgt)
- hard_sus = [s for s in sus if s.get("severity") == "hard"]
- st_out["ports"] = {
- "suspicious": sus,
- "hosts": opens,
- "light": worst if hard_sus else ("yellow" if sus else "green"),
- "n_suspicious": len(sus),
- "n_hard": len(hard_sus),
- }
- # Only hard findings pull the card colour (soft stays informational).
- if hard_sus:
- members = list(st_out.get("members") or [])
- members.append(
- {
- "role": "ports",
- "host": ",".join(
- f"{s.get('label') or s.get('port')}"
- + (f"@{s.get('host')}" if s.get("host") else "")
- for s in hard_sus[:6]
- ),
- "ok": False,
- "light": worst,
- "reason": f"suspicious_ports_hard:{len(hard_sus)}",
- "down_since": None,
- "code": 0,
- "elapsed": 0.0,
- "error": None,
- "url": None,
- "endpoints": [],
- "endpoints_summary": summarize_endpoints([]),
- }
- )
- st_out["members"] = members
- light2, intensity2 = aggregate_stack(members)
- st_out["light"] = light2
- st_out["intensity"] = intensity2
- st_out["breakdown"] = aggregate_breakdown(members)
- return {
- "schema": SCHEMA,
- "ts": now,
- "recorder": os.environ.get("TALER_MONITORD_RECORDER") or socket.gethostname(),
- "acute_down_s": acute_s,
- "legend": dict(LEGEND),
- "stacks": out_stacks,
- "services": out_services,
- "mail": out_mail,
- "orphans": orphans,
- "ports": ports_doc,
- "published": {
- "enabled": published_doc.get("enabled"),
- "suite": published_doc.get("suite"),
- "error": published_doc.get("error"),
- "url": published_doc.get("url"),
- "packages": {
- k: published_doc.get("packages", {}).get(k)
- for k in (
- "taler-exchange",
- "taler-merchant",
- "libeufin-bank",
- "taler-merchant-webui",
- "taler-wallet-cli",
- )
- if (published_doc.get("packages") or {}).get(k)
- },
- "apps": published_doc.get("apps") or [],
- },
- "score": compute_total_score(out_stacks, out_services, out_mail),
- }
-
-
-def read_status(path: Path | None = None) -> dict[str, Any]:
- paths = [path] if path else status_json_paths()
- for p in paths:
- if p and p.is_file():
- try:
- return json.loads(p.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
- continue
- return {}
-
-
-def atomic_write_json(path: Path, doc: dict[str, Any]) -> None:
- path = Path(path)
- path.parent.mkdir(parents=True, exist_ok=True)
- text = json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
- tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
- try:
- tmp.write_text(text, encoding="utf-8")
- os.replace(tmp, path)
- finally:
- if tmp.exists():
- try:
- tmp.unlink()
- except OSError:
- pass
-
-
-def write_stack_status(doc: dict[str, Any], paths: list[Path] | None = None) -> Path:
- paths = paths or status_json_paths()
- primary = paths[0]
- for p in paths:
- atomic_write_json(p, doc)
- return primary
diff --git a/site-gen/taler-monitord b/site-gen/taler-monitord
deleted file mode 100755
index 8704e62..0000000
--- a/site-gen/taler-monitord
+++ /dev/null
@@ -1,224 +0,0 @@
-#!/usr/bin/env python3
-"""taler-monitord — probe domain stacks on an interval; UI reads JSON only."""
-from __future__ import annotations
-
-import argparse
-import os
-import subprocess
-import sys
-import tempfile
-import time
-from pathlib import Path
-
-HERE = Path(__file__).resolve().parent
-sys.path.insert(0, str(HERE))
-import stack_status_html as H # noqa: E402
-import stack_status_lib as L # noqa: E402
-
-# Match lib.sh: load ~/.config/taler-monitoring/env (or TALER_MONITORING_ENV), then
-# refresh module flags that were read at import time.
-_ENV_LOADED = L.load_monitoring_env()
-if _ENV_LOADED:
- L.refresh_runtime_flags_from_env()
-
-
-def _pid_path() -> Path:
- return L.monitord_state_dir() / "taler-monitord.pid"
-
-
-def _write_pid() -> None:
- _pid_path().write_text(str(os.getpid()) + "\n", encoding="utf-8")
-
-
-def _clear_pid() -> None:
- p = _pid_path()
- try:
- p.unlink()
- except OSError:
- pass
-
-
-def _publish_one(local: Path, ssh_host: str, remote: str) -> str | None:
- remote_dir = str(Path(remote).parent)
- mk = subprocess.run(
- [
- "ssh",
- "-o",
- "BatchMode=yes",
- "-o",
- "ConnectTimeout=8",
- ssh_host,
- f"mkdir -p -- {remote_dir!s}",
- ],
- capture_output=True,
- timeout=15,
- check=False,
- )
- if mk.returncode != 0:
- err = (mk.stderr or b"").decode("utf-8", "replace").strip()[:200]
- print(f"publish mkdir fail ({remote}): {err}", file=sys.stderr, flush=True)
- return None
- r = subprocess.run(
- [
- "scp",
- "-o",
- "BatchMode=yes",
- "-o",
- "ConnectTimeout=8",
- str(local),
- f"{ssh_host}:{remote}",
- ],
- capture_output=True,
- timeout=30,
- check=False,
- )
- if r.returncode != 0:
- err = (r.stderr or b"").decode("utf-8", "replace").strip()[:200]
- print(f"publish fail ({remote}): {err}", file=sys.stderr, flush=True)
- return None
- if "/var/www/monitoring-sites/" in remote:
- subprocess.run(
- [
- "ssh",
- "-o",
- "BatchMode=yes",
- "-o",
- "ConnectTimeout=8",
- ssh_host,
- (
- f"chmod 755 -- {remote_dir!s} && chmod 644 -- {remote!s} && "
- f"setfacl -m u:caddy:rx,m::r-x -- {remote_dir!s} && "
- f"setfacl -m u:caddy:r,m::r-- -- {remote!s} || true"
- ),
- ],
- capture_output=True,
- timeout=15,
- check=False,
- )
- else:
- subprocess.run(
- [
- "ssh",
- "-o",
- "BatchMode=yes",
- "-o",
- "ConnectTimeout=8",
- ssh_host,
- f"chmod 755 -- {remote_dir!s} && chmod 644 -- {remote!s} || true",
- ],
- capture_output=True,
- timeout=15,
- check=False,
- )
- return f"{ssh_host}:{remote}"
-
-
-def publish_html(doc: dict) -> str | None:
- """Render HTML and scp to Cyon (hacktivism) paths.
-
- Env:
- TALER_MONITORD_PUBLISH=0 to disable
- TALER_MONITORD_PUBLISH_SSH=hacktivism
- TALER_MONITORD_PUBLISH_PATH=… single path (overrides multi default)
- TALER_MONITORD_PUBLISH_PATHS=comma-separated paths
- Default paths: public_html/stacks + public_html/monitoring.hacktivism.ch
- """
- if os.environ.get("TALER_MONITORD_PUBLISH", "1") in ("0", "false", "no", ""):
- return None
- ssh_host = os.environ.get("TALER_MONITORD_PUBLISH_SSH", "hacktivism").strip() or "hacktivism"
- single = (os.environ.get("TALER_MONITORD_PUBLISH_PATH") or "").strip()
- multi = (os.environ.get("TALER_MONITORD_PUBLISH_PATHS") or "").strip()
- if single:
- remotes = [single]
- elif multi:
- remotes = [p.strip() for p in multi.split(",") if p.strip()]
- else:
- remotes = [
- "/home/hacktivi/public_html/stacks/index.html",
- "/home/hacktivi/public_html/monitoring.hacktivism.ch/index.html",
- ]
- html_text = H.render_html(doc)
- with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".html", delete=False) as f:
- f.write(html_text)
- local = Path(f.name)
- published: list[str] = []
- try:
- for remote in remotes:
- got = _publish_one(local, ssh_host, remote)
- if got:
- published.append(got)
- return "; ".join(published) if published else None
- except (OSError, subprocess.TimeoutExpired) as e:
- print(f"publish error: {e}", file=sys.stderr, flush=True)
- return None
- finally:
- try:
- local.unlink()
- except OSError:
- pass
-
-
-def run_once(*, domains: Path, out_paths: list[Path] | None, public_only: bool) -> Path:
- stacks = L.parse_domains_conf(domains)
- prev = L.read_status(out_paths[0] if out_paths else None)
- if not prev and out_paths:
- for p in out_paths:
- prev = L.read_status(p)
- if prev:
- break
- doc = L.build_stack_status(stacks, prev_doc=prev, public_only=public_only)
- paths = out_paths or L.status_json_paths()
- path = L.write_stack_status(doc, paths)
- # always keep a local HTML copy too
- local_html = L.monitord_state_dir() / "index.html"
- local_html.write_text(H.render_html(doc), encoding="utf-8")
- published = publish_html(doc)
- if published:
- print(f"published {published}", flush=True)
- return path
-
-
-def main() -> int:
- ap = argparse.ArgumentParser(description="Collect Taler stack status for the detached UI")
- ap.add_argument("--interval", type=float, default=float(os.environ.get("TALER_MONITORD_INTERVAL", "20")))
- ap.add_argument("--once", action="store_true", help="Single probe cycle then exit")
- ap.add_argument("--domains-conf", type=Path, default=None)
- ap.add_argument("--out", type=Path, action="append", default=None, help="Output JSON path (repeatable)")
- # Default on: include GOA / hacktivism.ch (taler.hacktivism.ch merchant). Opt out: --no-include-local
- _incl = os.environ.get("TALER_MONITORD_INCLUDE_LOCAL", "1") not in ("0", "false", "no", "")
- ap.add_argument(
- "--include-local",
- action=argparse.BooleanOptionalAction,
- default=_incl,
- help="Probe local=1 stacks (hacktivism GOA). Default on; --no-include-local to skip",
- )
- args = ap.parse_args()
- if _ENV_LOADED:
- print(f"env {_ENV_LOADED}", flush=True)
- domains = args.domains_conf or L.default_domains_conf()
- if not domains.is_file():
- print(f"domains.conf not found: {domains}", file=sys.stderr)
- return 2
- public_only = not bool(args.include_local)
- out_paths = list(args.out) if args.out else None
-
- if args.once:
- path = run_once(domains=domains, out_paths=out_paths, public_only=public_only)
- print(path)
- return 0
-
- _write_pid()
- try:
- while True:
- try:
- path = run_once(domains=domains, out_paths=out_paths, public_only=public_only)
- print(f"ok {path}", flush=True)
- except Exception as e: # keep loop alive
- print(f"error {type(e).__name__}: {e}", file=sys.stderr, flush=True)
- time.sleep(max(1.0, float(args.interval)))
- finally:
- _clear_pid()
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/taler-monitoring.sh b/taler-monitoring.sh
index cc55384..90af7ac 100755
--- a/taler-monitoring.sh
+++ b/taler-monitoring.sh
@@ -99,7 +99,6 @@ Phases:
max-ladder max-search: highest withdrawable + highest payable (≤ available)
goa-ladder alias of ladder (legacy phase name; same check_amount_ladder.sh)
auth401 merchant Basic-auth / case matrix (HTTP 401 paths; may create throwaway instance)
- ansible-smoke taler-deployment site.yml syntax + optional podman tracks (TASK112)
aptdeploy koopa podman apt-src smoke: taler-merchant in
koopa-taler-deploy-test-apt-src-trixie{,-testing}
surface REMOTE-ONLY public inventory (NOT in default/all/full):
@@ -284,7 +283,7 @@ while [ $# -gt 0 ]; do
shift
;;
--no-probe) NO_PROBE=1; shift ;;
- urls|inside|versions|sanity|server|e2e|zero-pay|zeropay|sticker-pay|template-zero|ladder|goa-ladder|max-ladder|maxladder|auth401|aptdeploy|apt-deploy|apt_src|ansible-smoke|ansible_smoke|surface|ecosystem|mattermost|mail|monpages|pages|devtesting|franken|fake-franken|fake_franken|all|full) PHASES+=("$1"); shift ;;
+ urls|inside|versions|sanity|server|e2e|zero-pay|zeropay|sticker-pay|template-zero|ladder|goa-ladder|max-ladder|maxladder|auth401|aptdeploy|apt-deploy|apt_src|surface|ecosystem|mattermost|mail|monpages|pages|devtesting|franken|fake-franken|fake_franken|all|full) PHASES+=("$1"); shift ;;
*)
# bare domain shorthand: ./taler-monitoring.sh taler.net
if [[ "$1" == *.* && "$1" != *://* && "$1" != -* ]]; then
@@ -309,11 +308,6 @@ if [ -n "$BANK_OVERRIDE" ]; then BANK_PUBLIC="$BANK_OVERRIDE"; fi
if [ -n "$EXCHANGE_OVERRIDE" ]; then EXCHANGE_PUBLIC="$EXCHANGE_OVERRIDE"; fi
if [ -n "$MERCHANT_OVERRIDE" ]; then MERCHANT_PUBLIC="$MERCHANT_OVERRIDE"; fi
-# Wire backend (x-taler-bank vs EBICS) after CLI overrides — sets CHECK_BANK for EBICS
-if [ "${TALER_SKIP_BANK_BACKEND_DETECT:-0}" != "1" ]; then
- resolve_taler_bank_backend
-fi
-
# Only koopa may use SSH. Remote domains: public + optional e2e (no SSH).
if [ "${LOCAL_STACK:-1}" != "1" ]; then
SKIP_SSH=1
@@ -330,7 +324,6 @@ fi
# Export so check_*.sh (re-source lib) see the same targets via env
export TALER_DOMAIN BANK_PUBLIC EXCHANGE_PUBLIC MERCHANT_PUBLIC
export EXPECT_CURRENCY SKIP_SSH LOCAL_STACK TALER_DOMAIN_PROBE CHECK_LANDING
-export CHECK_BANK CHECK_BANK_SET TALER_BANK_BACKEND TALER_BANK_BACKEND_SET
export TALER_DOMAIN_FROM_CLI="${TALER_DOMAIN_FROM_CLI:-0}"
export DOMAIN_SET="${DOMAIN_SET:-0}"
export WITHDRAW_AMT PAY_AMT CREDIT_AMT MERCHANT_INSTANCE
@@ -585,15 +578,9 @@ fi
printf 'target domain=%s\n' "${TALER_DOMAIN}"
printf ' ui_lang %s\n' "${TALER_MON_LANG:-en}"
printf ' locale %s\n' "${TALER_MON_LOCALE:-${TALER_DOMAIN_LOCALE:-?}}"
-if [ "${TALER_BANK_BACKEND:-}" = "ebics" ] || [ -z "${BANK_PUBLIC:-}" ]; then
- printf ' bank IBAN (EBICS — no public bank.*)\n'
-else
- printf ' bank %s\n' "${BANK_PUBLIC}"
-fi
+printf ' bank %s\n' "$BANK_PUBLIC"
printf ' exchange %s\n' "$EXCHANGE_PUBLIC"
printf ' merchant %s\n' "$MERCHANT_PUBLIC"
-printf ' wire backend=%s CHECK_BANK=%s\n' \
- "${TALER_BANK_BACKEND:-?}" "${CHECK_BANK:-1}"
printf ' currency expect=%s\n' "${EXPECT_CURRENCY:-any}"
printf ' phases %s\n' "${PHASES[*]}"
printf ' flags LOCAL_STACK=%s SKIP_SSH=%s INSIDE_PODMAN=%s INSIDE_MODE=%s\n' \
@@ -665,7 +652,6 @@ while [ "$_phase_idx" -lt "$_n_phases" ]; do
;;
auth401) run_phase auth401 "$ROOT/check_auth401.sh" || ec=1 ;;
aptdeploy|apt-deploy|apt_src) run_phase aptdeploy "$ROOT/check_apt_deploy.sh" || ec=1 ;;
- ansible-smoke|ansible_smoke) run_phase ansible-smoke "$ROOT/check_ansible_smoke.sh" || ec=1 ;;
surface|ecosystem) run_phase surface "$ROOT/check_surface.sh" || ec=1 ;;
mattermost) run_phase mattermost "$ROOT/check_mattermost.sh" || ec=1 ;;
mail) run_phase mail "$ROOT/check_mail.sh" || ec=1 ;;
diff --git a/tests/test_stack_status.py b/tests/test_stack_status.py
deleted file mode 100644
index 560c8c4..0000000
--- a/tests/test_stack_status.py
+++ /dev/null
@@ -1,1095 +0,0 @@
-#!/usr/bin/env python3
-"""Offline unit tests for stack_status_lib (no network)."""
-from __future__ import annotations
-
-import json
-import os
-import sys
-import tempfile
-import time
-import unittest
-from pathlib import Path
-from unittest import mock
-
-ROOT = Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT / "site-gen"))
-
-import stack_status_lib as L # noqa: E402
-
-
-def _disable_extra_planes() -> None:
- """Keep offline unit tests off dig/TLS/catalog network work."""
- L.SERVICES_ENABLED = False
- L.MAIL_ENABLED = False
- L.CERT_PROBES_ENABLED = False
- L.ORPHAN_PROBES_ENABLED = False
- L.MAIL_PORTS_ENABLED = False
- L.ORPHAN_HTTP_ENABLED = False
- L.PORT_SCAN_ENABLED = False
- L.DELIVER_ENABLED = False
-
-
-_disable_extra_planes()
-
-
-class AggregateTests(unittest.TestCase):
- def test_all_green_ok(self):
- members = [{"light": "green"}] * 3
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "green")
- self.assertEqual(intensity, "ok")
-
- def test_taler_software_from_config(self):
- body = json.dumps(
- {"name": "taler-exchange", "build_version": "1.6.15-dev.1", "version": "39:0:5"}
- ).encode()
- items = L.software_from_taler_config(body, host="exchange.demo.taler.net", role="exchange")
- self.assertEqual(len(items), 1)
- self.assertEqual(items[0]["package"], "taler-exchange")
- self.assertEqual(items[0]["version"], "1.6.15-dev.1")
- colored = L.colorize_software(
- items + L.software_from_server_header("nginx/1.24.0", host="exchange.demo.taler.net"),
- vulns={"hits": [{"package": "nginx", "version": "1.24.0", "severity": "HIGH"}]},
- )
- by = {c["package"]: c for c in colored}
- self.assertEqual(by["taler-exchange"]["light"], "green")
- self.assertEqual(by["nginx"]["light"], "orange")
-
- def test_deliverability_stale_and_fail(self):
- now = 1_000_000.0
- old_en = L.DELIVER_ENABLED
- L.DELIVER_ENABLED = True
- try:
- state = {
- "domains": {
- "example.test": {
- "ok": True,
- "light": "green",
- "reason": "rcpt_accepted",
- "ts": now - 100,
- "mx": "mx.example.test",
- "rcpt": "postmaster@example.test",
- }
- }
- }
- got = L.ensure_deliverability(
- "example.test", mx_hosts=["mx.example.test"], now=now, state=state, force=False
- )
- self.assertTrue(got["ok"])
- self.assertEqual(got["light"], "green")
- # stale success → yellow (prevent re-probe by raising interval)
- state["domains"]["example.test"]["ts"] = now - (L.DELIVER_STALE_S + 10)
- old_i = L.DELIVER_INTERVAL_S
- L.DELIVER_INTERVAL_S = L.DELIVER_STALE_S + 1000
- try:
- got = L.ensure_deliverability(
- "example.test", mx_hosts=["mx.example.test"], now=now, state=state, force=False
- )
- finally:
- L.DELIVER_INTERVAL_S = old_i
- self.assertFalse(got["ok"])
- self.assertEqual(got["light"], "yellow")
- self.assertTrue(got["stale"])
- # hard fail stored
- state["domains"]["example.test"] = {
- "ok": False,
- "light": "orange",
- "reason": "rcpt_reject:550",
- "ts": now - 60,
- "mx": "mx.example.test",
- "rcpt": "postmaster@example.test",
- }
- old_i = L.DELIVER_INTERVAL_S
- L.DELIVER_INTERVAL_S = 10_000
- try:
- got = L.ensure_deliverability(
- "example.test", mx_hosts=["mx.example.test"], now=now, state=state, force=False
- )
- finally:
- L.DELIVER_INTERVAL_S = old_i
- self.assertEqual(got["light"], "orange")
- self.assertIn("rcpt_reject", got["reason"])
- finally:
- L.DELIVER_ENABLED = old_en
-
- def test_published_currency_behind(self):
- self.assertEqual(L.normalize_build_version("1.6.15-dev.1"), "1.6.15~dev1")
- pub = {
- "enabled": True,
- "packages": {"taler-merchant": "1.6.22-0+trixie", "taler-exchange": "1.6.15~dev1-0+trixie"},
- }
- items = L.apply_published_currency(
- [
- {"package": "taler-merchant", "version": "1.6.21", "light": "green"},
- {"package": "taler-exchange", "version": "1.6.15-dev.1", "light": "green"},
- ],
- pub,
- )
- by = {i["package"]: i for i in items}
- self.assertEqual(by["taler-merchant"]["currency_status"], "behind")
- self.assertEqual(by["taler-merchant"]["behind"], "1.6.22")
- self.assertEqual(by["taler-exchange"]["currency_status"], "current")
- self.assertGreater(L.deb_version_cmp("1.6.22-0+trixie", "1.6.21-0+trixie"), 0)
-
- def test_port_whitelist_and_suspicious(self):
- rules = L.parse_ports_whitelist # noqa: ensure exists
- self.assertTrue(callable(rules))
- wl = [{"pattern": "*.hacktivism.ch", "ports": [22, 8080, 9200]}, {"pattern": "*", "ports": [8080]}]
- self.assertIn(22, L.whitelist_ports_for_host("taler.hacktivism.ch", wl))
- self.assertIn(9200, L.whitelist_ports_for_host("git.hacktivism.ch", wl))
- self.assertIn(8080, L.whitelist_ports_for_host("anything.example", wl))
- # Intentional koopa public ports must not be suspicious / orange
- koopa = L.assess_host_ports(
- "git.hacktivism.ch",
- ports=[22, 80, 443, 8080, 9200, 3306],
- whitelist_rules=wl,
- open_ports=[22, 80, 443, 8080, 9200],
- )
- self.assertEqual(koopa["suspicious"], [])
- self.assertTrue(koopa["ok"])
- self.assertEqual(koopa["light"], "green")
- got = L.assess_host_ports(
- "edge.example",
- ports=[22, 80, 443, 3306],
- whitelist_rules=[],
- open_ports=[80, 443, 3306],
- )
- self.assertEqual([s["port"] for s in got["suspicious"]], [3306])
- self.assertEqual(got["suspicious"][0]["severity"], "hard")
- self.assertEqual(got["light"], "orange")
- got2 = L.assess_host_ports(
- "edge.example",
- ports=[22, 80, 443],
- whitelist_rules=[{"pattern": "edge.example", "ports": [22]}],
- open_ports=[22, 443],
- )
- self.assertEqual(got2["suspicious"], [])
- self.assertTrue(got2["ok"])
- soft = L.assess_host_ports(
- "edge.example",
- ports=[22, 80, 443],
- whitelist_rules=[],
- open_ports=[22, 443],
- )
- self.assertEqual(soft["suspicious"][0]["severity"], "soft")
- self.assertTrue(soft["ok"]) # soft does not fail ok
-
- def test_orphan_skips_siblings_and_wildcards(self):
- known = {"demo.taler.net", "backend.demo.taler.net"}
- orphans = L.collect_orphan_hosts(
- known_hosts=known,
- tls_sans=["*.demo.taler.net", "donations.demo.taler.net", "gone.example.invalid"],
- probe_fn=lambda url: {"ok": False, "code": 0, "error": "URLError"},
- )
- hosts = [o["host"] for o in orphans]
- self.assertNotIn("demo.taler.net", hosts)
- self.assertNotIn("donations.demo.taler.net", hosts) # sibling
- # gone.example.invalid may appear only if dig says no DNS — skip assert on network
- self.assertTrue(all(not h.startswith("*.") for h in hosts))
-
- def test_total_score_thresholds(self):
- # 10 green of 10 → green
- s = L.compute_total_score([{"light": "green"}] * 10)
- self.assertEqual(s["green"], 10)
- self.assertEqual(s["total"], 10)
- self.assertEqual(s["light"], "green")
- # 9/10 → blue (≥0.85)
- s = L.compute_total_score([{"light": "green"}] * 9 + [{"light": "orange"}])
- self.assertEqual(s["light"], "blue")
- # 6/10 → yellow
- s = L.compute_total_score([{"light": "green"}] * 6 + [{"light": "red"}] * 4)
- self.assertEqual(s["light"], "yellow")
- # 4/10 → orange
- s = L.compute_total_score([{"light": "green"}] * 4 + [{"light": "red"}] * 6)
- self.assertEqual(s["light"], "orange")
- # 1/10 → red
- s = L.compute_total_score([{"light": "green"}] + [{"light": "orange"}] * 9)
- self.assertEqual(s["light"], "red")
- # 0 green mixed → white (gone)
- s = L.compute_total_score([{"light": "red"}, {"light": "orange"}])
- self.assertEqual(s["green"], 0)
- self.assertEqual(s["light"], "white")
- # all black → black
- s = L.compute_total_score([{"light": "black"}] * 3)
- self.assertEqual(s["light"], "black")
-
- def test_breakdown_counts(self):
- members = [
- {"role": "bank", "host": "a", "light": "green", "reason": "ok"},
- {"role": "exchange", "host": "b", "light": "green", "reason": "ok"},
- {"role": "merchant", "host": "c", "light": "orange", "reason": "http_501"},
- {"role": "bank", "host": "IBAN", "light": "blue", "reason": "ebics_iban"},
- ]
- bd = L.aggregate_breakdown(members)
- self.assertEqual(bd["by_light"].get("green"), 2)
- self.assertEqual(bd["by_light"].get("orange"), 1)
- self.assertNotIn("blue", bd["by_light"]) # skipped informational
- self.assertEqual(bd["worst"], "orange")
- self.assertEqual(bd["counted"], 3)
-
- def test_blue_present_ok(self):
- members = [{"light": "green"}, {"light": "blue"}, {"light": "blue"}]
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "blue")
- self.assertEqual(intensity, "ok")
-
- def test_one_yellow_muted(self):
- members = [
- {"light": "green"},
- {"light": "yellow"},
- {"light": "green"},
- ]
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "yellow")
- self.assertEqual(intensity, "muted")
-
- def test_one_red_muted(self):
- members = [
- {"light": "green"},
- {"light": "red"},
- {"light": "green"},
- ]
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "red")
- self.assertEqual(intensity, "muted")
-
- def test_all_red_full(self):
- members = [{"light": "red"}] * 3
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "red")
- self.assertEqual(intensity, "full")
-
- def test_all_black_full(self):
- members = [{"light": "black"}] * 3
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "black")
- self.assertEqual(intensity, "full")
-
- def test_mixed_orange_green_muted(self):
- members = [
- {"light": "orange"},
- {"light": "green"},
- {"light": "green"},
- ]
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "orange")
- self.assertEqual(intensity, "muted")
-
- def test_all_yellow_still_muted(self):
- # Full punch only for red/black total outage
- members = [{"light": "yellow"}] * 3
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "yellow")
- self.assertEqual(intensity, "muted")
-
- def test_weakest_wins_black_over_red(self):
- members = [
- {"light": "red"},
- {"light": "black"},
- {"light": "green"},
- ]
- light, intensity = L.aggregate_stack(members)
- self.assertEqual(light, "black")
- self.assertEqual(intensity, "muted")
-
-
-class ClassifyTests(unittest.TestCase):
- def test_ok_green(self):
- c = L.classify_probe({"ok": True, "code": 200, "elapsed": 0.2, "error": None}, now=1000.0)
- self.assertEqual(c["light"], "green")
- self.assertIsNone(c["down_since"])
-
- def test_slow_yellow(self):
- c = L.classify_probe({"ok": True, "code": 200, "elapsed": 3.0, "error": None}, now=1000.0)
- self.assertEqual(c["light"], "yellow")
-
- def test_acute_red(self):
- c = L.classify_probe(
- {"ok": False, "code": 0, "elapsed": 1.0, "error": "URLError"},
- prev={"ok": True},
- now=1000.0,
- acute_s=90,
- )
- self.assertEqual(c["light"], "red")
- self.assertEqual(c["down_since"], 1000.0)
-
- def test_long_black(self):
- c = L.classify_probe(
- {"ok": False, "code": 0, "elapsed": 1.0, "error": "URLError"},
- prev={"ok": False, "down_since": 1000.0},
- now=1200.0,
- acute_s=90,
- )
- self.assertEqual(c["light"], "black")
- self.assertEqual(c["down_since"], 1000.0)
-
- def test_timeout_orange_when_fresh(self):
- c = L.classify_probe(
- {"ok": False, "code": 0, "elapsed": 5.0, "error": "TimeoutError"},
- prev={"ok": True},
- now=1000.0,
- acute_s=90,
- )
- self.assertEqual(c["light"], "orange")
-
-
-class EndpointTests(unittest.TestCase):
- def test_legal_missing_is_medium_orange(self):
- for label in ("privacy", "terms"):
- c = L.classify_endpoint_probe(
- {"ok": False, "code": 404, "elapsed": 0.1, "error": "http_404", "body": b""},
- expect=(200,),
- severity="medium",
- now=1000.0,
- )
- self.assertEqual(c["light"], "orange", label)
- self.assertFalse(c["expected"], label)
-
- def test_summarize_dots(self):
- eps = [
- {"light": "green", "skipped": False},
- {"light": "yellow", "skipped": False},
- {"light": "blue", "skipped": True},
- ]
- s = L.summarize_endpoints(eps)
- self.assertEqual(s["ok"], 1)
- self.assertEqual(s["soft"], 1)
- self.assertEqual(s["skipped"], 1)
- self.assertEqual(s["light"], "yellow")
- self.assertEqual(s["dots"], ["green", "yellow"]) # skips omitted from strip
-
- def test_probe_role_endpoints_intro_skip(self):
- calls = []
-
- def probe(url: str):
- calls.append(url)
- return {"ok": True, "code": 200, "elapsed": 0.05, "error": None, "body": b"{}", "url": url}
-
- eps = L.probe_role_endpoints(
- "bank",
- "bank.example.test",
- has_intro=False,
- probe_fn=probe,
- now=1000.0,
- )
- ids = {e["id"]: e for e in eps}
- self.assertTrue(ids["intro"]["skipped"])
- self.assertEqual(ids["intro"]["reason"], "no_intro")
- self.assertFalse(ids["config"]["skipped"])
- self.assertTrue(any(u.endswith("/config") for u in calls))
- self.assertFalse(any("/intro/" in u for u in calls))
-
- def test_custom_intro_stacks_only(self):
- self.assertTrue(
- L.stack_has_custom_intro({"currency": "GOA", "id": "hacktivism.ch", "kind": "taler"})
- )
- self.assertTrue(
- L.stack_has_custom_intro(
- {"currency": "TESTPAYSAN", "id": "stage.lefrancpaysan.ch", "kind": "taler"}
- )
- )
- self.assertFalse(
- L.stack_has_custom_intro({"currency": "KUDOS", "id": "demo.taler.net", "kind": "taler"})
- )
- self.assertFalse(
- L.stack_has_custom_intro({"currency": "CHF", "id": "taler-ops.ch", "kind": "taler"})
- )
- self.assertFalse(
- L.stack_has_custom_intro(
- {"currency": "", "id": "lefrancpaysan.ch", "kind": "static_pending"}
- )
- )
-
- def test_server_header_parse(self):
- self.assertEqual(L.guess_http_package("nginx/1.22.1"), "nginx")
- self.assertEqual(L.extract_http_version("nginx/1.22.1"), "1.22.1")
- self.assertEqual(L.guess_http_package("Caddy"), "caddy")
-
- def test_vuln_raises_box_colour(self):
- members = [
- {"role": "exchange", "host": "ex.example", "light": "green", "reason": "ok"},
- {"role": "merchant", "host": "m.example", "light": "green", "reason": "ok"},
- ]
- vulns = {
- "enabled": True,
- "worst": "CRITICAL",
- "light": "red",
- "hits": [
- {
- "id": "CVE-2099-0001",
- "severity": "CRITICAL",
- "package": "nginx",
- "version": "1.0.0",
- "host": "ex.example",
- }
- ],
- }
- light, intensity, members2 = L.apply_vuln_to_stack("green", "ok", members, vulns)
- self.assertEqual(light, "red")
- self.assertEqual(intensity, "muted")
- self.assertEqual(members2[-1]["role"], "cve")
-
- def test_network_zones_and_ssh(self):
- self.assertEqual(
- L.classify_network_zone(
- {"id": "hacktivism.ch", "currency": "GOA", "local": True, "kind": "taler"}
- )["zone"],
- "goa-local",
- )
- self.assertEqual(
- L.classify_network_zone(
- {"id": "demo.taler.net", "currency": "KUDOS", "local": False, "kind": "taler"}
- )["zone"],
- "public-demo",
- )
- targets = L.ssh_targets_for_stack(
- {
- "id": "hacktivism.ch",
- "currency": "GOA",
- "local": True,
- "kind": "taler",
- "merchant": "taler.hacktivism.ch",
- }
- )
- self.assertTrue(targets["expected"])
- self.assertTrue(any("hacktivism" in h for h in targets["hosts"]))
-
- def fake_ssh(host: str):
- return {
- "ok": True,
- "light": "green",
- "reason": "SSH-2.0-OpenSSH_test",
- "host": host,
- "elapsed": 0.05,
- "banner": "SSH-2.0-OpenSSH_test",
- }
-
- net = L.build_network_status(
- {
- "id": "hacktivism.ch",
- "currency": "GOA",
- "local": True,
- "kind": "taler",
- "merchant": "taler.hacktivism.ch",
- },
- members=[{"light": "green", "reason": "ok"}],
- ssh_probe_fn=fake_ssh,
- )
- self.assertEqual(net["zone"], "goa-local")
- self.assertTrue(net["ssh"]["ok"])
- self.assertIn("hacktivism", net["ssh"]["host"])
-
- def test_tops_stage_ssh_is_env_mapped_hidden(self):
- st = {
- "id": "stage.taler-ops.ch",
- "currency": "CHF",
- "kind": "taler",
- "local": False,
- "merchant": "stage.my.taler-ops.ch",
- "exchange": "exchange.stage.taler-ops.ch",
- }
- self.assertEqual(L.classify_network_zone(st)["zone"], "tops-stage")
- with mock.patch.dict(
- os.environ, {"TALER_MONITORD_SSH_TOPS": "betel.taler-systems.com"}
- ):
- targets = L.ssh_targets_for_stack(st)
- self.assertEqual(targets["hosts"], ["betel.taler-systems.com"])
- self.assertTrue(targets.get("hide_host"))
- self.assertNotIn("stage.taler-ops.ch", targets["hosts"])
-
- def fake_ssh(host: str):
- self.assertEqual(host, "betel.taler-systems.com")
- return {
- "ok": True,
- "light": "green",
- "reason": "SSH-2.0-OpenSSH_test",
- "host": host,
- "elapsed": 0.05,
- "banner": "SSH-2.0-OpenSSH_test",
- }
-
- net = L.build_network_status(
- st, members=[{"light": "green", "reason": "ok"}], ssh_probe_fn=fake_ssh
- )
- self.assertTrue(net["ssh"]["ok"])
- # UI/JSON must not advertise env-mapped host
- self.assertEqual(net["ssh"]["host"], "")
- self.assertEqual(net["ssh"]["candidates"], [])
-
- def test_tops_stage_ssh_without_env_uses_public_fqdn(self):
- st = {
- "id": "stage.taler-ops.ch",
- "currency": "CHF",
- "kind": "taler",
- "local": False,
- "merchant": "stage.my.taler-ops.ch",
- "exchange": "exchange.stage.taler-ops.ch",
- }
- cleared = {
- k: ""
- for k in (
- "TALER_MONITORD_SSH_TOPS",
- "TALER_MONITORD_SSH_FP",
- "TALER_MONITORD_SSH_GOA",
- "TALER_MONITORD_SSH_TOPS_PROD",
- )
- }
- with mock.patch.dict(os.environ, cleared):
- targets = L.ssh_targets_for_stack(st)
- self.assertEqual(targets["hosts"][0], "stage.taler-ops.ch")
- self.assertFalse(targets.get("hide_host"))
-
- def test_fp_stage_ssh_is_env_mapped_hidden(self):
- st = {
- "id": "stage.lefrancpaysan.ch",
- "currency": "TESTPAYSAN",
- "kind": "taler",
- "local": False,
- "merchant": "stage.monnaie.lefrancpaysan.ch",
- "exchange": "stage.exchange.lefrancpaysan.ch",
- "bank": "stage.bank.lefrancpaysan.ch",
- }
- self.assertEqual(L.classify_network_zone(st)["zone"], "fp-stage")
- with mock.patch.dict(
- os.environ, {"TALER_MONITORD_SSH_FP": "ov-1a5ee8.infomaniak.ch"}
- ):
- targets = L.ssh_targets_for_stack(st)
- self.assertEqual(targets["hosts"], ["ov-1a5ee8.infomaniak.ch"])
- self.assertTrue(targets.get("hide_host"))
- self.assertNotIn("lefrancpaysan", " ".join(targets["hosts"]))
-
- def fake_ssh(host: str):
- self.assertEqual(host, "ov-1a5ee8.infomaniak.ch")
- return {
- "ok": True,
- "light": "green",
- "reason": "SSH-2.0-OpenSSH_test",
- "host": host,
- "elapsed": 0.05,
- "banner": "SSH-2.0-OpenSSH_test",
- }
-
- net = L.build_network_status(
- st, members=[{"light": "green", "reason": "ok"}], ssh_probe_fn=fake_ssh
- )
- self.assertTrue(net["ssh"]["ok"])
- self.assertEqual(net["ssh"]["host"], "")
- self.assertEqual(net["ssh"]["candidates"], [])
-
- def test_load_monitoring_env_expands_home(self):
- with tempfile.TemporaryDirectory() as td:
- p = Path(td) / "env"
- p.write_text(
- "TALER_MONITORD_SSH_TOPS=betel.example\n"
- "SUITE_DIR=$HOME/git/taler-monitoring\n"
- "# comment\n"
- "export TALER_MONITORD_SSH_FP=fp.example\n",
- encoding="utf-8",
- )
- with mock.patch.dict(os.environ, {}, clear=False):
- for k in (
- "TALER_MONITORD_SSH_TOPS",
- "TALER_MONITORD_SSH_FP",
- "SUITE_DIR",
- ):
- os.environ.pop(k, None)
- got = L.load_monitoring_env(path=p)
- self.assertEqual(got, p)
- self.assertEqual(os.environ["TALER_MONITORD_SSH_TOPS"], "betel.example")
- self.assertEqual(os.environ["TALER_MONITORD_SSH_FP"], "fp.example")
- self.assertTrue(os.environ["SUITE_DIR"].endswith("/git/taler-monitoring"))
-
- def test_commercial_prod_tops_only(self):
- self.assertTrue(
- L.stack_is_commercial_prod(
- {"id": "taler-ops.ch", "currency": "CHF", "kind": "taler", "local": False}
- )
- )
- self.assertEqual(
- L.legal_doc_severity(
- {"id": "taler-ops.ch", "currency": "CHF", "kind": "taler", "local": False}
- ),
- "medium",
- )
- self.assertFalse(
- L.stack_is_commercial_prod(
- {"id": "stage.taler-ops.ch", "currency": "CHF", "kind": "taler", "local": False}
- )
- )
- self.assertEqual(
- L.legal_doc_severity(
- {"id": "stage.taler-ops.ch", "currency": "CHF", "kind": "taler", "local": False}
- ),
- "soft",
- )
- self.assertFalse(
- L.stack_is_commercial_prod(
- {"id": "hacktivism.ch", "currency": "GOA", "kind": "taler", "local": True}
- )
- )
- self.assertFalse(
- L.stack_is_commercial_prod(
- {"id": "demo.taler.net", "currency": "KUDOS", "kind": "taler", "local": False}
- )
- )
-
-
-class CurrencySpecTests(unittest.TestCase):
- def test_summarize_chf(self):
- cs = {
- "name": "Swiss francs",
- "currency": "CHF",
- "alt_unit_names": {"0": "Fr.", "-2": "Rp."},
- "common_amounts": ["CHF:5", "CHF:10", "CHF:25", "CHF:50"],
- }
- info = L.summarize_currency_spec(cs)
- self.assertEqual(info["currency"], "CHF")
- self.assertEqual(info["symbol"], "Fr.")
- self.assertEqual(info["units_label"], "Fr. / Rp.")
- self.assertEqual(info["denoms"], ["5", "10", "25", "50"])
-
- def test_denoms_from_keys_when_common_empty(self):
- cs = {"currency": "FLODOS", "alt_unit_names": {"0": "FLODOS"}, "common_amounts": []}
- body = b'{"denominations":[{"value":"FLODOS:1"},{"value":"FLODOS:5"},{"value":"FLODOS:0"}]}'
- info = L.summarize_currency_spec(cs, keys_body=body)
- self.assertEqual(info["denoms"], ["1", "5"])
-
-
-class WireBackendTests(unittest.TestCase):
- def test_payto_methods(self):
- blob = b'{"accounts":[{"payto_uri":"payto://iban/CH9300762011623852957"},{"payto_uri":"payto://x-taler-bank/bank.example/ex"}]}'
- self.assertEqual(L.payto_wire_methods(blob), {"iban", "x-taler-bank"})
-
- def test_classify_ebics_iban_only(self):
- self.assertEqual(L.classify_bank_backend({"iban"}), "ebics")
-
- def test_classify_xtaler_wins_mixed(self):
- self.assertEqual(L.classify_bank_backend({"iban", "x-taler-bank"}), "x-taler-bank")
-
- def test_classify_conf_skip(self):
- self.assertEqual(L.classify_bank_backend(set(), bank_configured=False), "ebics")
- self.assertFalse(L.bank_host_configured("-"))
- self.assertFalse(L.bank_host_configured("ebics"))
- self.assertTrue(L.bank_host_configured("bank.example.test"))
-
-
-class ParseAndBuildTests(unittest.TestCase):
- def setUp(self):
- _disable_extra_planes()
-
- def test_parse_stage_fp(self):
- conf = ROOT / "domains.conf"
- stacks = L.parse_domains_conf(conf)
- ids = {s["id"] for s in stacks}
- self.assertIn("stage.lefrancpaysan.ch", ids)
- stage = next(s for s in stacks if s["id"] == "stage.lefrancpaysan.ch")
- self.assertEqual(stage["bank"], "stage.bank.lefrancpaysan.ch")
- self.assertEqual(stage["merchant"], "stage.monnaie.lefrancpaysan.ch")
- self.assertEqual(stage["currency"], "TESTPAYSAN")
- self.assertEqual(stage["kind"], "taler")
- prod = next(s for s in stacks if s["id"] == "lefrancpaysan.ch")
- self.assertEqual(prod["kind"], "static_pending")
- self.assertEqual(prod["currency"], "")
-
- def test_ebics_skips_bank_probe(self):
- stacks = [
- {
- "id": "tops.example",
- "bank": "bank.tops.example",
- "exchange": "exchange.tops.example",
- "merchant": "my.tops.example",
- "currency": "CHF",
- "kind": "taler",
- "local": False,
- }
- ]
- probed = []
-
- def probe(url: str):
- probed.append(url)
- if "/keys" in url:
- body = b'{"accounts":[{"payto_uri":"payto://iban/CH6808573105529100001?receiver-name=X"}]}'
- return {"ok": True, "code": 200, "elapsed": 0.1, "error": None, "body": body, "url": url}
- if "bank." in url:
- return {"ok": False, "code": 0, "elapsed": 0.1, "error": "URLError", "body": b"", "url": url}
- return {"ok": True, "code": 200, "elapsed": 0.1, "error": None, "body": b"{}", "url": url}
-
- doc = L.build_stack_status(stacks, probe_fn=probe, now=5000.0, public_only=False)
- st = doc["stacks"][0]
- self.assertEqual(st["bank_backend"], "ebics")
- self.assertEqual(st["light"], "green")
- self.assertEqual(st["intensity"], "ok")
- roles = {m["role"]: m for m in st["members"]}
- self.assertEqual(roles["bank"]["reason"], "ebics_iban")
- self.assertEqual(roles["bank"]["light"], "blue")
- self.assertEqual(roles["bank"]["host"], "IBAN")
- self.assertTrue(all("bank.tops" not in u for u in probed))
-
- def test_xtaler_bank_still_probed(self):
- stacks = [
- {
- "id": "demo.example",
- "bank": "bank.demo.example",
- "exchange": "exchange.demo.example",
- "merchant": "backend.demo.example",
- "currency": "KUDOS",
- "kind": "taler",
- "local": False,
- }
- ]
-
- def probe(url: str):
- if "/keys" in url:
- body = b'{"accounts":[{"payto_uri":"payto://x-taler-bank/bank.demo.example/exchange"}]}'
- return {"ok": True, "code": 200, "elapsed": 0.1, "error": None, "body": body, "url": url}
- if "bank." in url:
- return {"ok": False, "code": 0, "elapsed": 0.1, "error": "URLError", "body": b"", "url": url}
- return {"ok": True, "code": 200, "elapsed": 0.1, "error": None, "body": b"{}", "url": url}
-
- doc = L.build_stack_status(stacks, probe_fn=probe, now=5000.0, public_only=False)
- st = doc["stacks"][0]
- self.assertEqual(st["bank_backend"], "x-taler-bank")
- self.assertEqual(st["light"], "red")
- self.assertEqual(st["intensity"], "muted")
- roles = {m["role"]: m for m in st["members"]}
- self.assertEqual(roles["bank"]["light"], "red")
-
- def test_domains_conf_bank_dash(self):
- with tempfile.TemporaryDirectory() as td:
- conf = Path(td) / "domains.conf"
- conf.write_text(
- "ebics.example - exchange.ebics.example my.ebics.example CHF 0 0 en de-CH\n",
- encoding="utf-8",
- )
- stacks = L.parse_domains_conf(conf)
- self.assertEqual(len(stacks), 1)
- self.assertFalse(stacks[0]["bank_configured"])
- self.assertEqual(stacks[0]["bank"], "")
-
- def test_landing_stats_summary(self):
- stacks = [
- {
- "id": "stage.example",
- "bank": "bank.stage.example",
- "exchange": "exchange.stage.example",
- "merchant": "monnaie.stage.example",
- "currency": "TESTPAYSAN",
- "kind": "taler",
- "local": False,
- }
- ]
- stats_body = json.dumps(
- {
- "ok": True,
- "currency": "TESTPAYSAN",
- "generated_at_unix": 5000.0 - 120,
- "generated_at_human": "just now",
- "source": "stage-shared",
- "bank_accounts": {"total": 36, "users": 34},
- "withdraws": {"count": 45, "total_amount": "TESTPAYSAN:12"},
- }
- ).encode()
-
- cfg_body = json.dumps(
- {
- "currency": "TESTPAYSAN",
- "currency_specification": {
- "name": "TESTPAYSAN stage currency",
- "currency": "TESTPAYSAN",
- "alt_unit_names": {"0": "TESTPAYSAN", "-2": "ct"},
- "common_amounts": [
- "TESTPAYSAN:5",
- "TESTPAYSAN:10",
- "TESTPAYSAN:20",
- "TESTPAYSAN:50",
- "TESTPAYSAN:100",
- ],
- },
- }
- ).encode()
-
- def probe(url: str):
- if url.endswith("/intro/stats.json") and "bank.stage" in url:
- return {
- "ok": True,
- "code": 200,
- "elapsed": 0.05,
- "error": None,
- "body": stats_body,
- "url": url,
- }
- if "/keys" in url:
- body = b'{"accounts":[{"payto_uri":"payto://x-taler-bank/bank.stage.example/exchange"}]}'
- return {"ok": True, "code": 200, "elapsed": 0.1, "error": None, "body": body, "url": url}
- if url.endswith("/config") and "exchange.stage" in url:
- return {
- "ok": True,
- "code": 200,
- "elapsed": 0.05,
- "error": None,
- "body": cfg_body,
- "url": url,
- }
- return {"ok": True, "code": 200, "elapsed": 0.1, "error": None, "body": b"{}", "url": url}
-
- doc = L.build_stack_status(stacks, probe_fn=probe, now=5000.0, public_only=False)
- st = doc["stacks"][0]
- self.assertIsNotNone(st.get("stats"))
- self.assertTrue(st["stats"]["present"])
- self.assertEqual(st["stats"]["age_s"], 120)
- self.assertIn("accounts 36", st["stats"]["lines"])
- self.assertFalse(st["stats"]["stale"])
- self.assertEqual(st["currency"], "TESTPAYSAN")
- self.assertEqual(st["currency_info"]["symbol"], "TESTPAYSAN")
- self.assertEqual(st["currency_info"]["units_label"], "TESTPAYSAN / ct")
- self.assertEqual(st["currency_info"]["denoms"], ["5", "10", "20", "50", "100"])
-
- def test_static_pending_softens_api_down(self):
- stacks = [
- {
- "id": "lefrancpaysan.ch",
- "bank": "bank.lefrancpaysan.ch",
- "exchange": "exchange.lefrancpaysan.ch",
- "merchant": "monnaie.lefrancpaysan.ch",
- "currency": "",
- "kind": "static_pending",
- "local": False,
- }
- ]
-
- def probe(url: str):
- if url.rstrip("/").endswith("lefrancpaysan.ch"):
- return {"ok": True, "code": 200, "elapsed": 0.1, "error": None, "url": url}
- return {"ok": False, "code": 502, "elapsed": 0.1, "error": "http_502", "url": url}
-
- doc = L.build_stack_status(stacks, probe_fn=probe, now=5000.0, public_only=False)
- st = doc["stacks"][0]
- self.assertEqual(st["kind"], "static_pending")
- self.assertEqual(st["light"], "blue")
- self.assertEqual(st["intensity"], "ok")
- roles = {m["role"]: m for m in st["members"]}
- self.assertEqual(roles["site"]["light"], "green")
- self.assertEqual(roles["bank"]["reason"], "no_prod_yet")
- self.assertEqual(roles["bank"]["light"], "blue")
-
- def test_build_with_mock_probe(self):
- stacks = [
- {
- "id": "example.test",
- "bank": "bank.example.test",
- "exchange": "exchange.example.test",
- "merchant": "merch.example.test",
- "currency": "X",
- "local": False,
- }
- ]
-
- def probe(url: str):
- if "exchange" in url:
- return {"ok": False, "code": 0, "elapsed": 0.1, "error": "URLError", "url": url}
- return {"ok": True, "code": 200, "elapsed": 0.1, "error": None, "url": url}
-
- doc = L.build_stack_status(stacks, probe_fn=probe, now=5000.0, public_only=False)
- self.assertEqual(len(doc["stacks"]), 1)
- st = doc["stacks"][0]
- self.assertEqual(st["light"], "red")
- self.assertEqual(st["intensity"], "muted")
- self.assertIn("breakdown", st)
- self.assertEqual(doc.get("schema"), L.SCHEMA)
-
- def test_atomic_write_roundtrip(self):
- with tempfile.TemporaryDirectory() as td:
- path = Path(td) / "stack-status.json"
- doc = {"schema": 1, "stacks": []}
- L.atomic_write_json(path, doc)
- self.assertTrue(path.is_file())
- got = L.read_status(path)
- self.assertEqual(got["schema"], 1)
-
- def test_parse_services_conf(self):
- with tempfile.TemporaryDirectory() as td:
- conf = Path(td) / "services-catalog.conf"
- conf.write_text(
- "bugs.gnunet.org bugs.gnunet.org MantisBT firefly\n"
- "erp.stage.taler-systems.com erp.stage.taler-systems.com ERPnext pixel\n",
- encoding="utf-8",
- )
- rows = L.parse_services_conf(conf)
- self.assertEqual(len(rows), 2)
- self.assertEqual(rows[0]["service_label"], "MantisBT")
- self.assertEqual(rows[1]["zone"], "pixel")
-
-
-class HtmlRenderTests(unittest.TestCase):
- def test_degraded_why_and_cve_link(self):
- import stack_status_html as H
-
- doc = {
- "schema": L.SCHEMA,
- "ts": time.time(),
- "acute_down_s": 90,
- "stacks": [
- {
- "id": "my.taler-ops.ch",
- "light": "orange",
- "intensity": "muted",
- "currency": "CHF",
- "bank_backend": "ebics",
- "kind": "taler",
- "breakdown": {"by_light": {"green": 4, "orange": 3}, "worst": "orange", "counted": 7},
- "members": [
- {"role": "bank", "host": "IBAN", "light": "blue", "reason": "ebics_iban"},
- {"role": "exchange", "host": "exchange.taler-ops.ch", "light": "green", "reason": "ok"},
- {
- "role": "merchant",
- "host": "my.taler-ops.ch",
- "light": "orange",
- "reason": "http_501",
- },
- ],
- "vulns": {
- "enabled": True,
- "worst": "HIGH",
- "hits": [
- {
- "id": "CVE-2024-1234",
- "severity": "HIGH",
- "package": "nginx",
- "version": "1.24.0",
- "summary": "test vuln",
- "host": "my.taler-ops.ch",
- }
- ],
- "servers": [
- {"host": "my.taler-ops.ch", "package": "nginx", "version": "1.24.0", "server": "nginx/1.24.0"}
- ],
- },
- "network": {"zone": "tops-prod", "ssh": {"expected": False}},
- "tls": {"days_left": 60, "sans": ["my.taler-ops.ch"]},
- }
- ],
- "services": [],
- "mail": [],
- "orphans": [],
- }
- html = H.render_html(doc)
- self.assertIn("4× OK", html)
- self.assertIn("3× Degraded", html)
- self.assertIn("osv.dev/vulnerability/CVE-2024-1234", html)
- self.assertIn("openStack", html)
- self.assertIn("aria-label=\"nginx\"", html)
- self.assertIn("0/1 green", html) # orange stack → not green
- self.assertIn("Gone", html) # or Degraded label in score — 0 green → white/Gone
-
- def test_git_ls_remote_soft_probe_mocked(self):
- calls: list[list[str]] = []
-
- def fake_run(cmd, **kwargs):
- calls.append(list(cmd))
-
- class R:
- returncode = 0
- stdout = b"abc123\trefs/heads/main\n"
- stderr = b""
-
- return R()
-
- with tempfile.TemporaryDirectory() as td:
- with mock.patch.dict(
- os.environ, {"TALER_MONITORD_STATE": td, "TALER_MONITORD_GIT": "1"}
- ):
- L.refresh_runtime_flags_from_env()
- with mock.patch("stack_status_lib.subprocess.run", side_effect=fake_run):
- got = L.collect_git_probe("git.example", now=1000.0, force=True)
- self.assertTrue(got["ok"])
- self.assertEqual(got["light"], "green")
- self.assertGreaterEqual(len(calls), 2) # https + ssh
- n = len(calls)
- with mock.patch("stack_status_lib.subprocess.run", side_effect=fake_run):
- cached = L.collect_git_probe("git.example", now=1100.0, force=False)
- self.assertTrue(cached["ok"])
- self.assertEqual(len(calls), n) # interval cache hit
-
- def test_result_index_optional_by_colour_and_cmd(self):
- import stack_status_html as H
-
- doc = {
- "schema": L.SCHEMA,
- "ts": time.time(),
- "acute_down_s": 90,
- "stacks": [
- {
- "id": "stage.example",
- "light": "yellow",
- "intensity": "muted",
- "currency": "CHF",
- "kind": "taler",
- "members": [
- {
- "role": "exchange",
- "host": "exchange.stage.example",
- "light": "green",
- "reason": "ok",
- "endpoints": [
- {
- "id": "config",
- "path": "/config",
- "url": "https://exchange.stage.example/config",
- "light": "green",
- "reason": "ok",
- "skipped": False,
- },
- {
- "id": "keys",
- "path": "/keys",
- "url": "https://exchange.stage.example/keys",
- "light": "yellow",
- "reason": "slow",
- "skipped": False,
- },
- ],
- },
- {
- "role": "bank",
- "host": "IBAN",
- "light": "blue",
- "reason": "ebics_iban",
- },
- ],
- "network": {
- "zone": "tops-stage",
- "ssh": {
- "expected": True,
- "ok": True,
- "light": "green",
- "host": "",
- "candidates": [],
- "reason": "SSH-2.0-OpenSSH_test",
- },
- },
- }
- ],
- "services": [],
- "mail": [],
- }
- off = H.render_html(doc)
- self.assertNotIn('id="result-index"', off)
-
- colors = H._render_result_index(doc, mode="colors")
- self.assertIn('id="result-index"', colors)
- self.assertIn("all probes by colour", colors)
- self.assertIn("exchange/config", colors)
- self.assertIn("exchange/keys", colors)
- self.assertIn("bank", colors)
- self.assertIn("ssh", colors)
- self.assertNotIn("ri-cmd", colors)
-
- cmd = H._render_result_index(doc, mode="cmd")
- self.assertIn("with command", cmd)
- self.assertIn("GET https://exchange.stage.example/config", cmd)
- self.assertIn("GET https://exchange.stage.example/keys", cmd)
- self.assertIn("TCP", cmd)
-
-
-if __name__ == "__main__":
- unittest.main()