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'' - ) - - -_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 += ''; - 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
    '; - } - if (d.software && d.software.length) { - html += '
    Software
    '; - } - if (d.deliverability && (d.deliverability.reason || d.deliverability.light)) { - const deliv = d.deliverability; - html += '
    Deliverability
    '; - } - if (d.ports && d.ports.length) { - html += '
    Suspicious ports
    '; - } - 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 - - - -
    -

    LLM · stacks

    - {score_html} -
    updated {_esc(updated)} · Lightweight Monitoring
    -
    -{''.join(sections)} -{result_index_html} - - -
    -

    detail

    - -
    -
    -
    - - - -""" - - -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()