Demo images / Build & push demo images (push) Failing after 2m16s
Keeps the dev and test tooling an outside contributor would want, and takes our node addresses out of it. Scripts that silently defaulted to one of our nodes now require an explicit host and exit 2 without one: smoke-test.sh, trust-archipelago-cert.sh, dev-container-test.sh (which also derives its RPC and health URLs from the SSH target instead of a second hardcoded copy), and image-recipe/dev-branding.sh. A default that points at a machine the user does not own is worse than no default: it fails confusingly, or reaches a stranger's device. Usage examples, mock data and test fixtures move to the RFC 5737 documentation range (192.0.2.0/24). CGNAT test values stay inside 100.64.0.0/10 so the range-check semantics they exercise still hold, and 192.168.1.0/.1/.254 are left alone — those are gateway logic and UI placeholders, not our addresses. Playwright and the perf spec defaulted their baseURL to one of our nodes; they now default to localhost:8100, the local dev server. Removed neode-ui APP_URLS entirely. It is dead code — exported, never imported — and it pinned fedimint's *prod* launch URL to 192.168.1.228:8175. Had anything consumed it, every user's node would have tried to reach an address that on their LAN is either nothing or someone else's machine. Deleting beats sanitizing dead config. Verified: frontend 868/868 vitest across 108 files; archipelago-container 75/75; mesh tests 9/9; audit-secrets 5/5. Zero node addresses and zero node names remain in tracked files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
265 lines
14 KiB
Bash
Executable File
265 lines
14 KiB
Bash
Executable File
#!/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 / <FLEET_PW>
|
|
# 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.0.2.12 MB_URL=https://192.0.2.10 \
|
|
# MC_URL=https://192.0.2.11 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.0.2.12}"; MA_PW="${MA_PW:?MA_PW required — export it or set tests/multinode/.env (see .env.example)}"
|
|
MB_URL="${MB_URL:-https://192.0.2.10}"; 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"
|