Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit e502804df8
1634 changed files with 350014 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Local credentials for the multinode test suites — copy to tests/multinode/.env
# (git-ignored) and fill in. Sourced automatically by lib/multinode.bash.
# NEVER commit real node passwords.
# smoke.sh / repro-federation-sync.sh
A_PW=changeme # node A (default URL http://192.168.1.116)
B_PW=changeme # node B (default URL https://192.168.1.228)
#C_URL=https://x.x.x.x # optional third node
#C_PW=changeme
# meshtastic.sh
MA_PW=changeme
MB_PW=changeme
#MC_URL=https://x.x.x.x
#MC_PW=changeme
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Multi-node RPC harness library.
#
# Unlike tests/lifecycle/lib/rpc.bash (which targets a single ARCHY_HOST),
# this drives N independent archipelago nodes in one run so we can exercise
# real node-to-node paths: federation sync over Tor, FIPS anchoring, etc.
#
# A "node handle" is a short label (e.g. A, B, alice). For each handle you
# register a base URL + UI password; the lib logs in and keeps that node's
# session/CSRF cookies in its own state file so calls never cross wires.
#
# Usage:
# source tests/multinode/lib/multinode.bash
# node_register A https://192.168.1.228 "$A_PW"
# node_register B http://192.168.1.116 "$B_PW"
# node_login A; node_login B
# node_rpc A node.tor-address
# node_result B federation.list-nodes
#
# Requires: curl, jq.
#
# Credentials: node passwords are NEVER committed. Export *_PW env vars, or
# put them in tests/multinode/.env (git-ignored; see .env.example) — sourced
# automatically below so every suite picks them up.
#
# Note: this is a library — it does NOT set shell options (set -u/-e), since
# that would leak into the sourcing script. Each function guards its own vars
# with ${var:-} defaults. Callers set their own options.
# Auto-load git-ignored local credentials (tests/multinode/.env), if present.
_MN_ENV_FILE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)/.env"
# shellcheck disable=SC1090
[[ -f "$_MN_ENV_FILE" ]] && source "$_MN_ENV_FILE"
# Where per-node session state lives (one file per handle).
MULTINODE_STATE_DIR="${MULTINODE_STATE_DIR:-/tmp/archy-multinode}"
mkdir -p "$MULTINODE_STATE_DIR"
# handle -> base url / password, kept in associative arrays.
declare -gA _MN_URL
declare -gA _MN_PW
declare -gA _MN_SESSION
declare -gA _MN_CSRF
# node_register HANDLE BASE_URL PASSWORD
node_register() {
local h="$1" url="$2" pw="$3"
_MN_URL[$h]="${url%/}"
_MN_PW[$h]="$pw"
}
_mn_session_file() { echo "$MULTINODE_STATE_DIR/session-$1"; }
# node_login HANDLE — authenticate and capture session + csrf cookies.
node_login() {
local h="$1"
local url="${_MN_URL[$h]:-}" pw="${_MN_PW[$h]:-}"
if [[ -z "$url" || -z "$pw" ]]; then
echo "node_login: handle '$h' not registered" >&2
return 1
fi
local headers; headers=$(mktemp)
local body
body=$(curl -sk --connect-timeout 10 --max-time "${MULTINODE_RPC_TIMEOUT:-120}" \
-D "$headers" -X POST "${url}/rpc/v1" \
-H 'Content-Type: application/json' \
--data-raw "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"${pw}\"},\"id\":1}")
local err; err=$(echo "$body" | jq -r '.error.message // empty' 2>/dev/null)
if [[ -n "$err" ]]; then
echo "node_login[$h] failed: $err" >&2
rm -f "$headers"
return 1
fi
local session csrf
session=$(grep -i '^set-cookie: session=' "$headers" | head -1 | sed -E 's/.*session=([^;]+).*/\1/' | tr -d '\r')
csrf=$(grep -i '^set-cookie: csrf_token=' "$headers" | head -1 | sed -E 's/.*csrf_token=([^;]+).*/\1/' | tr -d '\r')
rm -f "$headers"
if [[ -z "$session" || -z "$csrf" ]]; then
echo "node_login[$h]: missing session/csrf cookie" >&2
return 1
fi
_MN_SESSION[$h]="$session"
_MN_CSRF[$h]="$csrf"
printf '%s\n%s\n' "$session" "$csrf" > "$(_mn_session_file "$h")"
}
# node_rpc HANDLE METHOD [PARAMS_JSON] — raw JSON-RPC response on stdout.
node_rpc() {
local h="$1" method="$2" params="${3:-}"
local url="${_MN_URL[$h]:-}"
local session="${_MN_SESSION[$h]:-}" csrf="${_MN_CSRF[$h]:-}"
if [[ -z "$session" || -z "$csrf" ]] && [[ -f "$(_mn_session_file "$h")" ]]; then
mapfile -t lines < "$(_mn_session_file "$h")"
session="${lines[0]:-}"; csrf="${lines[1]:-}"
_MN_SESSION[$h]="$session"; _MN_CSRF[$h]="$csrf"
fi
local payload
if [[ -z "$params" ]]; then
payload=$(jq -nc --arg m "$method" '{jsonrpc:"2.0",method:$m,id:1}')
else
payload=$(jq -nc --arg m "$method" --argjson p "$params" '{jsonrpc:"2.0",method:$m,params:$p,id:1}')
fi
# Bounded so one slow/hung server-side RPC can't hang the whole suite;
# override per-run with MULTINODE_RPC_TIMEOUT (seconds).
curl -sk --connect-timeout 10 --max-time "${MULTINODE_RPC_TIMEOUT:-120}" \
-X POST "${url}/rpc/v1" \
-H 'Content-Type: application/json' \
-H "Cookie: session=${session}; csrf_token=${csrf}" \
-H "X-CSRF-Token: ${csrf}" \
--data-raw "$payload"
}
# node_result HANDLE METHOD [PARAMS_JSON] — .result on success; prints error to
# stderr and returns non-zero on RPC error.
node_result() {
local resp; resp=$(node_rpc "$@")
local err; err=$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null)
if [[ -n "$err" ]]; then
echo "node_result[$1 $2] error: $err" >&2
return 1
fi
echo "$resp" | jq '.result'
}
# node_onion HANDLE — echo this node's own .onion address (empty if none).
node_onion() {
node_result "$1" node.tor-address 2>/dev/null | jq -r '. // empty | if type=="object" then (.onion // .address // .tor_address // empty) else . end' 2>/dev/null
}
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# tests/multinode/meshtastic.sh — two-/three-radio Meshtastic parity harness.
#
# Validates that Meshtastic radios have the SAME mesh-tab features Meshcore got,
# done over the real wire. It drives 2 (optionally 3) archipelago nodes, each
# with a Meshtastic radio attached, and exercises the full message pipeline:
#
# 1. detect — each node reports a connected meshtastic device
# 2. discover — A sees B as a peer (NodeInfo discovery), and vice-versa
# 3. dm — A → B direct message round-trips (native unicast)
# 4. privacy — a third listener C does NOT see the A→B DM (proves the
# directed-unicast fix: DMs are not broadcast on the channel)
# 5. channel — A's channel broadcast IS seen by both B and C
# 6. typed — a typed envelope (reaction) round-trips with message_type set
# 7. assistant — (optional) an !ai query gets a PRIVATE reply, not a channel
# blast (gated on ASSIST=1 + assistant enabled on B)
# 8. reachable — reports each peer's `reachable`/`last_advert` so the ambiguous
# Meshtastic reachability semantics can be eyeballed on-air
# before anyone "fixes" them
#
# The privacy test (4) is the on-air proof of the meshtastic.rs send_text_msg
# unicast change. Without it, A→B DMs land on every node's channel feed.
#
# Nodes override via env (each must have a Meshtastic radio on the SAME LoRa
# channel/region so they can actually hear each other):
# MA_URL MA_PW node A (sender) default .116 http / ThisIsWeb54321@
# MB_URL MB_PW node B (receiver) default .228 https / password123
# MC_URL MC_PW node C (eavesdrop) OPTIONAL — enables privacy test (4)
#
# MB_NAME B's mesh node name, if A's peer list is ambiguous (>1 peer)
# PROP_WAIT seconds to wait for LoRa propagation per step (default 45)
# ASSIST set =1 to run the assistant private-reply test (7)
#
# Usage:
# tests/multinode/meshtastic.sh
# MA_URL=http://192.168.1.116 MB_URL=https://192.168.1.228 \
# MC_URL=https://192.168.1.198 tests/multinode/meshtastic.sh
#
# Requires: curl, jq. Exit code = number of failed assertions (0 = all green).
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/multinode.bash
source "$HERE/lib/multinode.bash"
# ── node registration ──────────────────────────────────────────────────────
MA_URL="${MA_URL:-http://192.168.1.116}"; MA_PW="${MA_PW:?MA_PW required — export it or set tests/multinode/.env (see .env.example)}"
MB_URL="${MB_URL:-https://192.168.1.228}"; MB_PW="${MB_PW:?MB_PW required — export it or set tests/multinode/.env (see .env.example)}"
MC_URL="${MC_URL:-}"; MC_PW="${MC_PW:-}"
PROP_WAIT="${PROP_WAIT:-45}"
MB_NAME="${MB_NAME:-}"
ASSIST="${ASSIST:-0}"
node_register A "$MA_URL" "$MA_PW"
node_register B "$MB_URL" "$MB_PW"
HAVE_C=0
if [[ -n "$MC_URL" ]]; then node_register C "$MC_URL" "$MC_PW"; HAVE_C=1; fi
# ── tiny assert framework (mirrors smoke.sh) ───────────────────────────────
if [[ -t 1 ]]; then
green() { printf '\033[32m%s\033[0m' "$*"; }
red() { printf '\033[31m%s\033[0m' "$*"; }
yellow() { printf '\033[33m%s\033[0m' "$*"; }
else
green() { printf '%s' "$*"; }; red() { printf '%s' "$*"; }; yellow() { printf '%s' "$*"; }
fi
PASS=0; FAIL=0; SKIP=0; declare -a FAILED_NAMES
ok() { printf ' %s %s\n' "$(green ✓)" "$1"; PASS=$((PASS+1)); }
no() { printf ' %s %s\n' "$(red ✗)" "$1"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); }
skip() { printf ' %s %s (%s)\n' "$(yellow —)" "$1" "${2:-skipped}"; SKIP=$((SKIP+1)); }
assert_true() { [[ "$2" == "true" ]] && ok "$1" || no "$1 (got '$2')"; }
section() { printf '\n%s\n' "$(yellow "── $* ──")"; }
# nonce for this run so message matches can't collide with stale history
NONCE="mtparity-$$-${RANDOM}"
# ── helpers ────────────────────────────────────────────────────────────────
# mesh_connected HANDLE -> "true" if a meshtastic device is connected
mesh_connected() {
local s; s=$(node_result "$1" mesh.status 2>/dev/null) || { echo false; return; }
local conn type
conn=$(echo "$s" | jq -r '.device_connected // false')
type=$(echo "$s" | jq -r '.device_type // "unknown"')
[[ "$conn" == "true" && "$type" == "meshtastic" ]] && echo true || echo false
}
# self_name HANDLE -> this node's meshtastic long-name (from firmware_version)
self_name() {
node_result "$1" mesh.status 2>/dev/null | jq -r '.firmware_version // empty'
}
# contact_id_for HANDLE NAME -> the contact_id of the peer whose advert_name
# matches NAME (case-insensitive substring); empty if not found / ambiguous.
contact_id_for() {
local h="$1" want="$2"
node_result "$h" mesh.peers 2>/dev/null | jq -r --arg w "$want" '
[.peers[] | select((.advert_name // "" | ascii_downcase)
| contains($w | ascii_downcase))] as $m
| if ($m|length)==1 then ($m[0].contact_id|tostring) else "" end'
}
# peer_count_excl_self HANDLE -> number of peers
peer_count() { node_result "$1" mesh.peers 2>/dev/null | jq -r '.count // 0'; }
# saw_text HANDLE NEEDLE [direction] -> "true" if a message whose plaintext
# contains NEEDLE exists (optionally filtered to a direction: sent/received)
saw_text() {
local h="$1" needle="$2" dir="${3:-}"
node_result "$h" mesh.messages '{"limit":200}' 2>/dev/null | jq -r --arg n "$needle" --arg d "$dir" '
[.messages[] | select((.plaintext // "") | contains($n))
| select($d=="" or (.direction==$d))] | length > 0'
}
# wait_text HANDLE NEEDLE — poll up to PROP_WAIT for a received message
wait_text() {
local h="$1" needle="$2" waited=0
while (( waited < PROP_WAIT )); do
[[ "$(saw_text "$h" "$needle" received)" == "true" ]] && return 0
sleep 3; waited=$((waited+3))
done
return 1
}
# ── login ──────────────────────────────────────────────────────────────────
section "login"
node_login A && ok "A login ($MA_URL)" || { no "A unreachable ($MA_URL)"; echo; exit 1; }
node_login B && ok "B login ($MB_URL)" || { no "B unreachable ($MB_URL)"; echo; exit 1; }
if (( HAVE_C )); then
node_login C && ok "C login ($MC_URL)" || { skip "C login" "unreachable — privacy test disabled"; HAVE_C=0; }
fi
# ── 1. detect ──────────────────────────────────────────────────────────────
section "1. device detection"
A_CONN=$(mesh_connected A); B_CONN=$(mesh_connected B)
assert_true "A has a connected meshtastic radio" "$A_CONN"
assert_true "B has a connected meshtastic radio" "$B_CONN"
if [[ "$A_CONN" != "true" || "$B_CONN" != "true" ]]; then
printf '\n%s\n' "$(yellow 'Both A and B need a Meshtastic radio attached & mesh enabled.')"
printf '%s\n' "$(yellow 'Aborting on-air tests; see mesh.status output above.')"
echo; printf 'PASS=%d FAIL=%d SKIP=%d\n' "$PASS" "$FAIL" "$SKIP"; exit "$FAIL"
fi
A_NAME=$(self_name A); B_NAME=$(self_name B)
printf ' A=%s B=%s\n' "${A_NAME:-?}" "${B_NAME:-?}"
[[ -n "$MB_NAME" ]] && B_NAME="$MB_NAME"
# ── 2. peer discovery ──────────────────────────────────────────────────────
section "2. peer discovery (NodeInfo)"
DISCO=0; waited=0
while (( waited < PROP_WAIT )); do
CID=$(contact_id_for A "${B_NAME:-Meshtastic}")
[[ -n "$CID" ]] && { DISCO=1; break; }
# fall back: any single non-channel peer
if [[ -z "$MB_NAME" && "$(peer_count A)" == "1" ]]; then
CID=$(node_result A mesh.peers | jq -r '.peers[0].contact_id'); DISCO=1; break
fi
sleep 3; waited=$((waited+3))
done
if (( DISCO )); then ok "A discovered B as a peer (contact_id=$CID)"
else
no "A did not discover B within ${PROP_WAIT}s"
printf ' A peers: %s\n' "$(node_result A mesh.peers | jq -c '.peers[]? | {contact_id,advert_name}')"
fi
# ── 3. direct message round-trip ───────────────────────────────────────────
section "3. direct message (native unicast)"
if (( DISCO )); then
DM="$NONCE-dm hello-from-A"
if node_result A mesh.send "$(jq -nc --argjson c "$CID" --arg m "$DM" '{contact_id:$c,message:$m}')" >/dev/null; then
ok "A sent DM to B (contact_id=$CID)"
if wait_text B "$NONCE-dm"; then ok "B received the DM"
else no "B did not receive the DM within ${PROP_WAIT}s"; fi
else no "mesh.send failed on A"; fi
else skip "DM round-trip" "B not discovered"; fi
# ── 4. privacy: third node must NOT see the DM ─────────────────────────────
section "4. DM privacy (directed, not broadcast)"
if (( HAVE_C )) && (( DISCO )); then
C_CONN=$(mesh_connected C)
if [[ "$C_CONN" != "true" ]]; then
skip "DM privacy" "C has no meshtastic radio"
else
# Give C the same window the DM had to propagate, then assert absence.
sleep "$PROP_WAIT"
if [[ "$(saw_text C "$NONCE-dm")" == "true" ]]; then
no "C (eavesdropper) saw the A→B DM — it is being BROADCAST, not unicast"
else
ok "C did NOT see the A→B DM (directed unicast confirmed)"
fi
fi
else
skip "DM privacy" "needs MC_URL (third radio) + discovered peer"
fi
# ── 5. channel broadcast reaches everyone ──────────────────────────────────
section "5. channel broadcast"
CH="$NONCE-chan broadcast-to-all"
if node_result A mesh.send-channel "$(jq -nc --arg m "$CH" '{channel:0,message:$m}')" >/dev/null; then
ok "A sent a channel broadcast"
if wait_text B "$NONCE-chan"; then ok "B received the broadcast"; else no "B missed the broadcast"; fi
if (( HAVE_C )) && [[ "$(mesh_connected C)" == "true" ]]; then
if [[ "$(saw_text C "$NONCE-chan")" == "true" ]]; then ok "C also received the broadcast"
else no "C missed the broadcast (it should reach all channel members)"; fi
fi
else no "mesh.send-channel failed on A"; fi
# ── 6. typed envelope round-trip ───────────────────────────────────────────
section "6. typed message (reaction envelope)"
if (( DISCO )); then
# A reaction is the smallest typed envelope; it should arrive with a
# non-"text" message_type, proving the typed pipeline works over Meshtastic.
REACT_PARAMS=$(jq -nc --argjson c "$CID" --arg n "$NONCE" \
'{contact_id:$c, emoji:"👍", target_seq:0, note:$n}')
if node_result A mesh.send-reaction "$REACT_PARAMS" >/dev/null 2>&1; then
ok "A sent a reaction (typed envelope)"
sleep "$PROP_WAIT"
TYPED=$(node_result B mesh.messages '{"limit":200}' 2>/dev/null \
| jq -r '[.messages[] | select(.message_type != null and .message_type != "text")] | length > 0')
assert_true "B received a non-text typed message" "$TYPED"
else
skip "typed message" "mesh.send-reaction rejected params (check handler signature)"
fi
else skip "typed message" "B not discovered"; fi
# ── 7. assistant private reply (optional) ──────────────────────────────────
section "7. AI assistant private reply (optional)"
if [[ "$ASSIST" == "1" ]] && (( DISCO )); then
AST=$(node_result B mesh.assistant-status 2>/dev/null | jq -r '.enabled // false')
if [[ "$AST" != "true" ]]; then
skip "assistant reply" "assistant not enabled on B"
else
Q="$NONCE-ai !ai are you there"
node_result A mesh.send-channel "$(jq -nc --arg m "$Q" '{channel:0,message:$m}')" >/dev/null
sleep "$PROP_WAIT"
# A should get a private DM reply; C (if present) should NOT.
if [[ "$(saw_text A "$NONCE-ai-reply")" == "true" || "$(node_result A mesh.messages '{"limit":50}' | jq -r '[.messages[]|select(.direction=="received")]|length>0')" == "true" ]]; then
ok "A received an assistant reply"
else
no "A did not receive an assistant reply within ${PROP_WAIT}s"
fi
if (( HAVE_C )) && [[ "$(mesh_connected C)" == "true" ]]; then
# heuristic: the reply text shouldn't be on C's channel feed
skip "assistant reply privacy" "eyeball C's feed — automated check is heuristic"
fi
fi
else
skip "assistant reply" "set ASSIST=1 and enable the assistant on B to run"
fi
# ── 8. reachability snapshot (report-only) ─────────────────────────────────
section "8. reachability snapshot (report-only)"
node_result A mesh.peers 2>/dev/null | jq -r '.peers[]?
| " \(.advert_name // "?") reachable=\(.reachable) last_advert=\(.last_advert // 0)"'
printf '%s\n' "$(yellow ' NOTE: Meshtastic flood-routes; path_len is always 0xff, so `reachable`')"
printf '%s\n' "$(yellow ' may read true even for stale nodes. Confirm desired semantics here')"
printf '%s\n' "$(yellow ' before changing the refresh_contacts reachability rule.')"
# ── summary ────────────────────────────────────────────────────────────────
section "summary"
printf 'PASS=%s FAIL=%s SKIP=%s\n' "$(green "$PASS")" "$( ((FAIL)) && red "$FAIL" || green 0 )" "$(yellow "$SKIP")"
if (( FAIL )); then
printf 'failed:\n'; for n in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$n"; done
fi
exit "$FAIL"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Controlled two-node reproduction of node-to-node federation sync.
#
# Pairs two real nodes via federation.invite/join, triggers federation.sync-state
# in both directions, and reports which transport actually carried the call and
# any per-peer error. This is the controlled repro for the reported
# "Tor connection cloud->node not working" symptom: raw Tor transport is known
# good (see README), so this isolates whether the APP-level sync path works and,
# if it fails, surfaces the exact error string.
#
# Env (override as needed):
# A_URL A_PW node A base url + UI password (default .116 http)
# B_URL B_PW node B base url + UI password (default .228 https)
# FORCE_TOR=1 set both nodes' federation transport preference to Tor first
#
# Usage: tests/multinode/repro-federation-sync.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HERE/lib/multinode.bash"
A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:?A_PW required — export it or set tests/multinode/.env (see .env.example)}"
B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:?B_PW required — export it or set tests/multinode/.env (see .env.example)}"
bar() { printf '\n=== %s ===\n' "$*"; }
node_register A "$A_URL" "$A_PW"
node_register B "$B_URL" "$B_PW"
bar "login"
node_login A || { echo "A login failed"; exit 1; }
node_login B || { echo "B login failed"; exit 1; }
echo "A=$A_URL B=$B_URL logged in"
bar "onions"
A_ONION=$(node_onion A); B_ONION=$(node_onion B)
echo "A onion: ${A_ONION:-<none>}"
echo "B onion: ${B_ONION:-<none>}"
if [[ "${FORCE_TOR:-0}" == "1" ]]; then
bar "force federation transport = tor on both"
node_rpc A transport.set-preference '{"service":"federation","pref":"tor"}' | jq -c '.result // .error'
node_rpc B transport.set-preference '{"service":"federation","pref":"tor"}' | jq -c '.result // .error'
fi
bar "federation state BEFORE"
echo "A knows:"; node_result A federation.list-nodes | jq -r '.[]? | " \(.name // "?") did=\(.did[0:24])… last_seen=\(.last_seen // "never")"' 2>/dev/null || echo " (none/err)"
echo "B knows:"; node_result B federation.list-nodes | jq -r '.[]? | " \(.name // "?") did=\(.did[0:24])… last_seen=\(.last_seen // "never")"' 2>/dev/null || echo " (none/err)"
bar "pair: A invites, B joins"
INV_A=$(node_result A federation.invite)
CODE_A=$(echo "$INV_A" | jq -r '.code // empty')
echo "A invite code: ${CODE_A:0:40}"
if [[ -n "$CODE_A" ]]; then
node_result B federation.join "$(jq -nc --arg c "$CODE_A" '{code:$c}')" \
&& echo "B joined A" || echo "B join FAILED"
fi
bar "pair: B invites, A joins"
INV_B=$(node_result B federation.invite)
CODE_B=$(echo "$INV_B" | jq -r '.code // empty')
echo "B invite code: ${CODE_B:0:40}"
if [[ -n "$CODE_B" ]]; then
node_result A federation.join "$(jq -nc --arg c "$CODE_B" '{code:$c}')" \
&& echo "A joined B" || echo "A join FAILED"
fi
bar "trigger sync-state on A (A dials its peers)"
node_result A federation.sync-state | jq '.'
bar "trigger sync-state on B (B dials its peers)"
node_result B federation.sync-state | jq '.'
bar "federation state AFTER (look for fresh last_seen + transport)"
echo "A knows:"; node_result A federation.list-nodes | jq -r '.[]? | " \(.name // "?") last_seen=\(.last_seen // "never") transport=\(.last_transport // .transport // "?")"' 2>/dev/null
echo "B knows:"; node_result B federation.list-nodes | jq -r '.[]? | " \(.name // "?") last_seen=\(.last_seen // "never") transport=\(.last_transport // .transport // "?")"' 2>/dev/null
bar "done"
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# Two-node (optionally three-node) end-to-end smoke suite for the full app.
#
# Unlike repro-federation-sync.sh (a diagnostic that just prints state), this
# is an ASSERTION suite: every check is pass/fail and the script exits non-zero
# if any required check fails. It exercises the real node-to-node surface and
# specifically guards the bugs fixed in v1.7.94 / v1.7.95:
# - FIPS auto-connects to the public anchor (v1.7.94)
# - peer content browse works over the mesh, not just Tor (v1.7.95 — the
# `/content` catalog used to 404 over FIPS and never fall back to Tor)
# - a removed federation node stays removed, incl. transitive re-discovery
# (v1.7.95 tombstone) — the transitive case needs node C.
#
# Nodes (override via env):
# A_URL A_PW node A (default .116 http)
# B_URL B_PW node B (default .228 https)
# C_URL C_PW node C (OPTIONAL — enables the transitive-tombstone test)
#
# Requires both nodes on v1.7.95-alpha+ for the content-browse and tombstone
# checks; older peers SKIP those (reported, not failed).
#
# Usage:
# tests/multinode/smoke.sh
# A_URL=http://192.168.1.116 B_URL=https://192.168.1.228 tests/multinode/smoke.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HERE/lib/multinode.bash"
A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:?A_PW required — export it or set tests/multinode/.env (see .env.example)}"
B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:?B_PW required — export it or set tests/multinode/.env (see .env.example)}"
C_URL="${C_URL:-}"; C_PW="${C_PW:-}"
# ── tiny assertion framework ──────────────────────────────────────────────
PASS=0; FAIL=0; SKIP=0
declare -a FAILED_NAMES
green() { printf '\033[32m%s\033[0m' "$*"; }
red() { printf '\033[31m%s\033[0m' "$*"; }
yellow(){ printf '\033[33m%s\033[0m' "$*"; }
section() { printf '\n\033[1m── %s ──\033[0m\n' "$*"; }
ok() { printf ' %s %s\n' "$(green ✓)" "$1"; PASS=$((PASS+1)); }
no() { printf ' %s %s\n' "$(red ✗)" "$1"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); }
skip() { printf ' %s %s (%s)\n' "$(yellow —)" "$1" "$2"; SKIP=$((SKIP+1)); }
# assert_eq NAME EXPECTED ACTUAL
assert_eq() { [[ "$2" == "$3" ]] && ok "$1" || no "$1 (expected '$2', got '$3')"; }
# assert_true NAME VALUE — passes when VALUE is "true"
assert_true() { [[ "$2" == "true" ]] && ok "$1" || no "$1 (got '$2')"; }
# did_of HANDLE — this node's own DID via node.did (string or {did:...}).
did_of() {
node_result "$1" node.did 2>/dev/null \
| jq -r 'if type=="string" then . elif type=="object" then (.did // .node_did // empty) else empty end' 2>/dev/null
}
# pair HANDLE_INVITER HANDLE_JOINER — invite + join one direction. Echo "ok"/"fail".
pair() {
local inv code
inv=$(node_result "$1" federation.invite 2>/dev/null)
code=$(echo "$inv" | jq -r '.code // empty' 2>/dev/null)
[[ -z "$code" ]] && { echo "fail"; return; }
if node_result "$2" federation.join "$(jq -nc --arg c "$code" '{code:$c}')" >/dev/null 2>&1; then
echo "ok"
else echo "fail"; fi
}
node_register A "$A_URL" "$A_PW"
node_register B "$B_URL" "$B_PW"
HAVE_C=0
if [[ -n "$C_URL" && -n "$C_PW" ]]; then node_register C "$C_URL" "$C_PW"; HAVE_C=1; fi
# ── 1. reachability + auth ────────────────────────────────────────────────
section "reachability + login"
node_login A && ok "A login ($A_URL)" || { no "A login ($A_URL)"; echo "A unreachable — aborting"; exit 1; }
node_login B && ok "B login ($B_URL)" || { no "B login ($B_URL)"; echo "B unreachable — aborting"; exit 1; }
if [[ $HAVE_C == 1 ]]; then node_login C && ok "C login ($C_URL)" || { no "C login"; HAVE_C=0; }; fi
A_ONION=$(node_onion A); B_ONION=$(node_onion B)
[[ -n "$A_ONION" ]] && ok "A has onion address" || no "A has onion address"
[[ -n "$B_ONION" ]] && ok "B has onion address" || no "B has onion address"
# ── 2. FIPS mesh: daemon up + anchor connected (v1.7.94) ──────────────────
section "FIPS mesh / anchor"
for h in A B; do
s=$(node_result "$h" fips.status 2>/dev/null)
if [[ -z "$s" ]]; then skip "$h fips.status" "no FIPS RPC (old build?)"; continue; fi
assert_true "$h FIPS service active" "$(echo "$s" | jq -r '.service_active')"
ac=$(echo "$s" | jq -r '.anchor_connected')
if [[ "$ac" == "true" ]]; then ok "$h anchor connected"
else skip "$h anchor connected" "anchor_connected=$ac — node may need v1.7.94 + a moment to handshake"; fi
done
# ── 3. federation pairing (both directions) ───────────────────────────────
section "federation pairing"
assert_eq "A invites, B joins" "ok" "$(pair A B)"
assert_eq "B invites, A joins" "ok" "$(pair B A)"
# both should now list each other
node_result A federation.sync-state >/dev/null 2>&1
node_result B federation.sync-state >/dev/null 2>&1
A_SEES_B=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg o "${B_ONION%.onion}" 'any((.nodes // .)[]?; (.onion // "" | gsub("\\.onion$";"")) == $o)')
B_SEES_A=$(node_result B federation.list-nodes 2>/dev/null | jq -r --arg o "${A_ONION%.onion}" 'any((.nodes // .)[]?; (.onion // "" | gsub("\\.onion$";"")) == $o)')
assert_true "A's node list contains B" "$A_SEES_B"
assert_true "B's node list contains A" "$B_SEES_A"
# ── 4. peer content browse over the mesh (v1.7.95 fix) ────────────────────
section "peer content browse (was: 404 over mesh, no Tor fallback)"
if [[ -n "$B_ONION" ]]; then
resp=$(node_rpc A content.browse-peer "$(jq -nc --arg o "$B_ONION" '{onion:$o}')")
err=$(echo "$resp" | jq -r '.error.message // empty')
if [[ -z "$err" ]]; then
ok "A browses B's content catalog (HTTP 200)"
elif echo "$err" | grep -q '404'; then
no "A browses B's content — still 404 over mesh (is B on v1.7.95?): $err"
else
# Other errors (peer offline, no content shared) are environmental, not the bug.
skip "A browses B's content" "non-404 error: $err"
fi
else
skip "A browses B's content" "B has no onion"
fi
# ── 5. removed-node tombstone (v1.7.95) ───────────────────────────────────
section "removed-node tombstone"
B_DID=$(did_of B)
if [[ -z "$B_DID" ]]; then
skip "remove B then verify stays removed" "couldn't resolve B's DID"
else
if node_result A federation.remove-node "$(jq -nc --arg d "$B_DID" '{did:$d}')" >/dev/null 2>&1; then
still=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
assert_eq "B removed from A's list" "false" "$still"
# Transitive test needs C: A federated with B and C; C federated with B;
# A removes B; A syncs with C (who advertises B) → B must NOT reappear.
if [[ $HAVE_C == 1 ]]; then
pair A C >/dev/null; pair C A >/dev/null; pair C B >/dev/null
node_result A federation.sync-state >/dev/null 2>&1
reappeared=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
assert_eq "B does NOT reappear via transitive sync with C" "false" "$reappeared"
else
skip "transitive reappear via 3rd node" "set C_URL/C_PW to enable"
fi
# re-add restores B (explicit re-add clears the tombstone)
pair B A >/dev/null
node_result A federation.sync-state >/dev/null 2>&1
readded=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
assert_true "explicit re-pair brings B back (tombstone cleared)" "$readded"
else
skip "remove B" "remove-node RPC failed (B may already be absent)"
fi
fi
# ── summary ───────────────────────────────────────────────────────────────
section "summary"
printf ' %s passed, %s failed, %s skipped\n' "$(green $PASS)" "$([[ $FAIL -gt 0 ]] && red $FAIL || echo $FAIL)" "$(yellow $SKIP)"
if [[ $FAIL -gt 0 ]]; then
printf ' failed:\n'; for n in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$n"; done
exit 1
fi
echo " all required checks passed"