mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
The harness had never actually been run. It now builds MDK 0.9.20 —
against the same OpenMLS fork rev our vector generator pins — boots a
local relay, brings up both wnd daemons and amy, and executes all 17
scenarios. They all still fail, downstream of MDK not finding A's
KeyPackage, but "it runs" is the difference between having an interop
signal and not having one.
Four environment blockers stood between preflight and a run: protoc is
now a build prerequisite; MDK 0.9.x needs WN_ALLOW_LOOPBACK_RELAYS=1
before it will accept a ws:// loopback relay at all; it refuses to create
its socket unless the parent directory is 0700; and `wn --json whoami`
moved to {"ok":true,"result":{"accounts":[…]}}, which the harness's
extractor probed right past.
Two defects in our own code came out of it.
`amy relay add` reported success from its DECISION to write rather than
from the store's answer, so a rejected or no-op write printed
`added: yes` and the caller only discovered otherwise much later.
The more consequential one: we read our OWN relay lists back through the
local-network filter. That filter is correct for someone else's list — it
is attacker-supplied input, and it is also what exempts a relay from Tor
— but applied to a list we published ourselves it made a deliberately
configured local relay look like no configuration at all. The publisher
then fell back to a default set, and the harness sent A's KeyPackage to
five PUBLIC relays instead of its loopback, which is the exact opposite
of what a "nothing leaves the machine" harness is for. `allRelays()` now
exists for reading back our own lists; the KeyPackage publish goes only
to the configured relay.
Test 01 is still blocked on a narrower puzzle: the kind-10051 list
persists under `relay key-package set` but not under `relay add`, while
kind 10050 works through the identical code path. That is a storage/CLI
thread, not a protocol one, and it needs its own pass.
Separately, and not a bug on either side: MDK accepts ws:// only for a
loopback host while quartz strips exactly those hosts from relay lists.
No address satisfies both, so a loopback-relay harness cannot pass until
one side moves — and changing a Tor-adjacent privacy guard is a
maintainer call, not one to make in passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
591 lines
25 KiB
Bash
591 lines
25 KiB
Bash
# shellcheck shell=bash
|
|
#
|
|
# lib.sh — helpers for marmot-interop.sh
|
|
#
|
|
# All functions assume the parent script has set:
|
|
# STATE_DIR absolute path to state/ directory
|
|
# LOG_FILE absolute path to current run's log file
|
|
# RESULTS_FILE absolute path to results table (tab-separated)
|
|
# WN_BIN, WND_BIN absolute paths to wn / wnd binaries
|
|
# B_SOCKET, C_SOCKET socket paths for the two daemons
|
|
|
|
set -u
|
|
|
|
# ------- colors --------------------------------------------------------------
|
|
if [[ -t 1 ]]; then
|
|
C_RESET=$'\033[0m'
|
|
C_BOLD=$'\033[1m'
|
|
C_RED=$'\033[31m'
|
|
C_GREEN=$'\033[32m'
|
|
C_YELLOW=$'\033[33m'
|
|
C_BLUE=$'\033[34m'
|
|
C_CYAN=$'\033[36m'
|
|
C_GREY=$'\033[90m'
|
|
else
|
|
C_RESET=""; C_BOLD=""; C_RED=""; C_GREEN=""
|
|
C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_GREY=""
|
|
fi
|
|
|
|
# ------- logging primitives --------------------------------------------------
|
|
log() { printf '%s\n' "$*" | tee -a "$LOG_FILE" >&2; }
|
|
|
|
banner() {
|
|
printf '\n%s%s================================================================%s\n' \
|
|
"$C_BOLD" "$C_BLUE" "$C_RESET" >&2
|
|
printf '%s%s %s%s\n' "$C_BOLD" "$C_BLUE" "$1" "$C_RESET" >&2
|
|
printf '%s%s================================================================%s\n\n' \
|
|
"$C_BOLD" "$C_BLUE" "$C_RESET" >&2
|
|
printf '\n=== %s ===\n' "$1" >>"$LOG_FILE"
|
|
}
|
|
|
|
step() { printf '%s>%s %s\n' "$C_CYAN" "$C_RESET" "$1" >&2; printf '> %s\n' "$1" >>"$LOG_FILE"; }
|
|
info() { printf '%s %s%s\n' "$C_GREY" "$1" "$C_RESET" >&2; printf ' %s\n' "$1" >>"$LOG_FILE"; }
|
|
warn() { printf '%s!%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; printf '! %s\n' "$1" >>"$LOG_FILE"; }
|
|
pass_msg() { printf '%s\u2713 PASS%s %s\n' "$C_GREEN" "$C_RESET" "$1" >&2; printf 'PASS %s\n' "$1" >>"$LOG_FILE"; }
|
|
fail_msg() { printf '%s\u2717 FAIL%s %s\n' "$C_RED" "$C_RESET" "$1" >&2; printf 'FAIL %s\n' "$1" >>"$LOG_FILE"; }
|
|
skip_msg() { printf '%s- SKIP%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; printf 'SKIP %s\n' "$1" >>"$LOG_FILE"; }
|
|
|
|
# ------- result tracking ------------------------------------------------------
|
|
# Each test calls record_result <test-id> <pass|fail|skip> [note]
|
|
record_result() {
|
|
local test_id="$1" status="$2" note="${3:-}"
|
|
printf '%s\t%s\t%s\n' "$test_id" "$status" "$note" >>"$RESULTS_FILE"
|
|
}
|
|
|
|
print_summary() {
|
|
local total=0 passed=0 failed=0 skipped=0
|
|
printf '\n%s%s================================================================%s\n' \
|
|
"$C_BOLD" "$C_BLUE" "$C_RESET"
|
|
printf '%s%s RESULTS%s\n' "$C_BOLD" "$C_BLUE" "$C_RESET"
|
|
printf '%s%s================================================================%s\n\n' \
|
|
"$C_BOLD" "$C_BLUE" "$C_RESET"
|
|
|
|
if [[ ! -s "$RESULTS_FILE" ]]; then
|
|
printf ' (no tests ran)\n\n'
|
|
return
|
|
fi
|
|
|
|
while IFS=$'\t' read -r tid status note; do
|
|
total=$((total+1))
|
|
case "$status" in
|
|
pass) passed=$((passed+1)); col="$C_GREEN"; sym="\u2713" ;;
|
|
fail) failed=$((failed+1)); col="$C_RED"; sym="\u2717" ;;
|
|
skip) skipped=$((skipped+1)); col="$C_YELLOW"; sym="-" ;;
|
|
*) col="$C_GREY"; sym="?" ;;
|
|
esac
|
|
printf ' %b %s%-44s%s %s\n' "$sym" "$col" "$tid" "$C_RESET" "$note"
|
|
done <"$RESULTS_FILE"
|
|
|
|
printf '\n %s%d passed%s, %s%d failed%s, %s%d skipped%s (of %d)\n\n' \
|
|
"$C_GREEN" "$passed" "$C_RESET" \
|
|
"$C_RED" "$failed" "$C_RESET" \
|
|
"$C_YELLOW" "$skipped" "$C_RESET" \
|
|
"$total"
|
|
printf ' full log: %s\n\n' "$LOG_FILE"
|
|
}
|
|
|
|
# ------- human interaction ---------------------------------------------------
|
|
|
|
# Pretty-print multi-line instructions and wait for <Enter>.
|
|
prompt_human() {
|
|
printf '\n%s%s---- DO THIS IN AMETHYST ----%s\n' "$C_BOLD" "$C_YELLOW" "$C_RESET" >&2
|
|
printf '%s%s%s\n' "$C_YELLOW" "$1" "$C_RESET" >&2
|
|
printf '%s%s-----------------------------%s\n' "$C_BOLD" "$C_YELLOW" "$C_RESET" >&2
|
|
printf '%s[Press Enter to continue]%s ' "$C_CYAN" "$C_RESET" >&2
|
|
read -r _
|
|
printf '\n' >>"$LOG_FILE"
|
|
printf 'HUMAN-PROMPT: %s\n' "$1" >>"$LOG_FILE"
|
|
}
|
|
|
|
# Ask the operator to confirm a UI rendering result.
|
|
# Returns 0 on pass, 1 on fail, 2 on skip.
|
|
confirm() {
|
|
local q="$1" ans
|
|
while true; do
|
|
printf '%s? %s%s [p]ass / [f]ail / [s]kip: ' "$C_CYAN" "$q" "$C_RESET" >&2
|
|
read -r ans
|
|
case "$(printf '%s' "$ans" | tr '[:upper:]' '[:lower:]')" in
|
|
p|pass) printf 'CONFIRM PASS: %s\n' "$q" >>"$LOG_FILE"; return 0 ;;
|
|
f|fail) printf 'CONFIRM FAIL: %s\n' "$q" >>"$LOG_FILE"; return 1 ;;
|
|
s|skip) printf 'CONFIRM SKIP: %s\n' "$q" >>"$LOG_FILE"; return 2 ;;
|
|
*) printf 'enter p, f, or s\n' >&2 ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ------- wn wrappers ---------------------------------------------------------
|
|
wn_b() { "$WN_BIN" --socket "$B_SOCKET" ${B_NPUB:+--account "$B_NPUB"} "$@"; }
|
|
wn_c() { "$WN_BIN" --socket "$C_SOCKET" ${C_NPUB:+--account "$C_NPUB"} "$@"; }
|
|
|
|
# Run wn with --json and pipe through jq; aborts the test on jq parse failure.
|
|
wn_b_json() { wn_b --json "$@"; }
|
|
wn_c_json() { wn_c --json "$@"; }
|
|
|
|
# ------- assertions ----------------------------------------------------------
|
|
expect_eq() {
|
|
local actual="$1" expected="$2" what="${3:-value}"
|
|
if [[ "$actual" == "$expected" ]]; then
|
|
info "$what == \"$expected\""
|
|
return 0
|
|
fi
|
|
fail_msg "$what mismatch: expected \"$expected\", got \"$actual\""
|
|
return 1
|
|
}
|
|
|
|
expect_contains() {
|
|
local haystack="$1" needle="$2" what="${3:-output}"
|
|
if [[ "$haystack" == *"$needle"* ]]; then
|
|
info "$what contains \"$needle\""
|
|
return 0
|
|
fi
|
|
fail_msg "$what missing \"$needle\""
|
|
info "actual: $haystack"
|
|
return 1
|
|
}
|
|
|
|
# ------- JSON helpers --------------------------------------------------------
|
|
|
|
# Extract MLS group ID as lowercase hex from wn JSON output.
|
|
# Handles both formats:
|
|
# - plain hex string (from `groups list`)
|
|
# - {"value":{"vec":[...]}} serde struct (from `groups create`)
|
|
# - flat byte array [n, ...] (from some responses)
|
|
# Plus the three wrapper shapes wn actually uses:
|
|
# - {"result": {"mls_group_id": ...}} (groups create)
|
|
# - {"group": {"mls_group_id": ...}, "membership": ...} (groups invites[0])
|
|
# - {"mls_group_id": ...} (bare)
|
|
# Input: JSON string via stdin; optional 2nd arg = field name (default: mls_group_id)
|
|
jq_group_id() {
|
|
local field="${1:-mls_group_id}"
|
|
jq -r --arg f "$field" '
|
|
def byte2hex:
|
|
. as $n |
|
|
[($n / 16 | floor), ($n % 16)] |
|
|
map(if . < 10 then (48 + .) else (87 + .) end) |
|
|
implode;
|
|
(.group // .result // .) |
|
|
(.group // .) |
|
|
.[$f] |
|
|
if type == "string" then .
|
|
elif (type == "object" and (.value.vec != null)) then
|
|
[.value.vec[] | byte2hex] | join("")
|
|
elif type == "array" then
|
|
[.[] | byte2hex] | join("")
|
|
else empty end
|
|
' 2>/dev/null || true
|
|
}
|
|
|
|
# ------- polling helpers -----------------------------------------------------
|
|
|
|
# Snapshot currently-pending invites on <B|C> as a comma-separated list of
|
|
# gids. Use this BEFORE triggering a publish that should produce a new
|
|
# welcome, then pass the result to `wait_for_invite` as the ignore list so
|
|
# a stale welcome left over from a previous harness run (wnd persists
|
|
# pending invites across restarts) doesn't register as the arrival we
|
|
# just triggered.
|
|
snapshot_invites() {
|
|
local who="$1" wnfn
|
|
if [[ "$who" == "B" ]]; then wnfn=wn_b
|
|
else wnfn=wn_c; fi
|
|
local raw
|
|
raw=$("$wnfn" --json groups invites 2>/dev/null || true)
|
|
# jq_group_id reads one invite at a time, so iterate the array in shell
|
|
# rather than trying to handle all shapes in a single jq expression.
|
|
local gids=()
|
|
while IFS= read -r one; do
|
|
[[ -z "$one" || "$one" == "null" ]] && continue
|
|
local g
|
|
g=$(printf '%s' "$one" | jq_group_id)
|
|
[[ -n "$g" ]] && gids+=("$g")
|
|
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
|
|
# bash 3.2 (stock macOS) treats "${gids[*]}" on an empty array as an
|
|
# unbound reference under `set -u`, so guard the expansion.
|
|
if (( ${#gids[@]} > 0 )); then
|
|
local IFS=','
|
|
printf '%s' "${gids[*]}"
|
|
fi
|
|
}
|
|
|
|
# wait_for_invite <B|C> <timeout-seconds> [ignore-csv]
|
|
# Echoes the first pending group_id that isn't listed in <ignore-csv>
|
|
# (comma-separated hex gids). Use the ignore list to skip welcomes left
|
|
# pending by previous harness runs — otherwise `wait_for_invite` can
|
|
# fire on a stale invite and the caller ends up driving a group the
|
|
# publisher isn't a member of, which then looks like a kind:445 drop.
|
|
# Returns 1 on timeout.
|
|
#
|
|
# Also prints a heartbeat every ~10s with:
|
|
# - elapsed/remaining time
|
|
# - current count of pending welcomes (total, pre-filter)
|
|
# - last relevant line of wnd stderr (grep for welcome/giftwrap/subscribe/error)
|
|
# so a stalled poll gives the operator something to forward.
|
|
wait_for_invite() {
|
|
local who="$1" timeout="${2:-60}" ignore_csv="${3:-}" deadline gid start last_hb
|
|
local wnfn data_dir
|
|
if [[ "$who" == "B" ]]; then wnfn=wn_b; data_dir="$B_DIR"
|
|
else wnfn=wn_c; data_dir="$C_DIR"; fi
|
|
start=$(date +%s)
|
|
deadline=$(( start + timeout ))
|
|
last_hb=$start
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
# Post-v0.2 `wn --json groups invites` returns `{"result": [...]}`
|
|
# (older builds returned the bare array). Peel the wrapper when
|
|
# present so a pending invite is actually detected.
|
|
local raw
|
|
raw=$("$wnfn" --json groups invites 2>/dev/null || true)
|
|
# Walk every pending invite (not just .[0]) so we skip past stales.
|
|
while IFS= read -r one; do
|
|
[[ -z "$one" || "$one" == "null" ]] && continue
|
|
gid=$(printf '%s' "$one" | jq_group_id)
|
|
[[ -z "$gid" ]] && continue
|
|
if [[ ",$ignore_csv," != *",$gid,"* ]]; then
|
|
printf '%s\n' "$gid"
|
|
return 0
|
|
fi
|
|
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
|
|
|
|
# Heartbeat every ~10s.
|
|
local now=$(date +%s)
|
|
if (( now - last_hb >= 10 )); then
|
|
local elapsed=$(( now - start )) remaining=$(( deadline - now ))
|
|
local pending
|
|
pending=$(printf '%s' "$raw" | jq '(.result // .) | length' 2>/dev/null || echo "?")
|
|
local recent=""
|
|
if [[ -f "$data_dir/logs/stderr.log" ]]; then
|
|
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
|
|
| grep -iE 'welcome|giftwrap|gift_wrap|mls|subscribe|1059|444|error|warn' \
|
|
| tail -n 1 || true)
|
|
fi
|
|
info "$who wait_for_invite +${elapsed}s (remaining ${remaining}s) pending=$pending ignoring=${ignore_csv:-<none>} tail: ${recent:-<no relevant wnd log yet>}"
|
|
last_hb=$now
|
|
fi
|
|
|
|
sleep 2
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# wait_for_message <B|C> <group_id> <substring> <timeout>
|
|
# Returns 0 if a message containing the substring shows up via wn messages list.
|
|
wait_for_message() {
|
|
local who="$1" gid="$2" needle="$3" timeout="${4:-30}"
|
|
local deadline payload
|
|
deadline=$(( $(date +%s) + timeout ))
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
if [[ "$who" == "B" ]]; then
|
|
payload=$(wn_b_json messages list "$gid" --limit 20 2>/dev/null || true)
|
|
else
|
|
payload=$(wn_c_json messages list "$gid" --limit 20 2>/dev/null || true)
|
|
fi
|
|
if [[ -n "${payload:-}" ]] && \
|
|
printf '%s' "$payload" | jq -e --arg n "$needle" \
|
|
'(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \
|
|
>/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# wait_for_member <B|C> <group_id> <pubkey-hex> <timeout>
|
|
wait_for_member() {
|
|
local who="$1" gid="$2" pubkey="$3" timeout="${4:-30}"
|
|
local deadline payload
|
|
deadline=$(( $(date +%s) + timeout ))
|
|
while [[ $(date +%s) -lt $deadline ]]; do
|
|
if [[ "$who" == "B" ]]; then
|
|
payload=$(wn_b_json groups members "$gid" 2>/dev/null || true)
|
|
else
|
|
payload=$(wn_c_json groups members "$gid" 2>/dev/null || true)
|
|
fi
|
|
if printf '%s' "${payload:-}" | jq -e --arg p "$pubkey" \
|
|
'(.result // .) | .[]? | select((.pubkey // .public_key // "") == $p)' \
|
|
>/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ------- output parsing ------------------------------------------------------
|
|
# `wn` prints either JSON (with --json) or a yaml-ish "key: value" pretty form.
|
|
# This helper extracts the pubkey (npub) from whichever form we got.
|
|
extract_pubkey() {
|
|
local raw="$1" v=""
|
|
# JSON: single object
|
|
v=$(printf '%s' "$raw" | jq -r '.pubkey // .npub // .public_key // empty' 2>/dev/null || true)
|
|
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
|
|
# JSON: {"result": [ {"pubkey": …}, … ]} — post-v0.2 `wn --json whoami` shape
|
|
v=$(printf '%s' "$raw" | jq -r '.result[0].pubkey // .result[0].npub // .result[0].public_key // empty' 2>/dev/null || true)
|
|
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
|
|
# JSON: {"ok":true,"result":{"accounts":[{"npub": ...}, ...]}} — MDK 0.9.x.
|
|
# `result` became an object with a named list, so the array-indexed probes
|
|
# above miss it entirely and the caller sees an empty npub.
|
|
v=$(printf '%s' "$raw" | jq -r '.result.accounts[0].npub // .result.accounts[0].pubkey // empty' 2>/dev/null || true)
|
|
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
|
|
# JSON: array of accounts (whoami may return a list)
|
|
v=$(printf '%s' "$raw" | jq -r '.[0].pubkey // .[0].npub // .[0].public_key // empty' 2>/dev/null || true)
|
|
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
|
|
# yaml-ish "pubkey: npub1..."
|
|
v=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*pubkey[[:space:]]*:[[:space:]]*//p' | head -n1)
|
|
if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi
|
|
# yaml-ish "npub: npub1..." (alternative key)
|
|
v=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*npub[[:space:]]*:[[:space:]]*//p' | head -n1)
|
|
if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi
|
|
}
|
|
|
|
# Pure-awk bech32 decoder for npub1... → 32-byte lowercase hex. Needed because
|
|
# `wn users show` only resolves identities the local daemon already knows
|
|
# about, so A's npub (the Amethyst account) can't be converted via wn — and a
|
|
# failed conversion leaks the raw npub into hex-equality checks like the
|
|
# member-list assertion in Test 02, which then fails with
|
|
# "member list missing npub1…".
|
|
bech32_decode_npub() {
|
|
local npub="$1"
|
|
[[ "$npub" == npub1* ]] || return 1
|
|
local body="${npub#npub1}"
|
|
# Need at least 6 chars of checksum + some data.
|
|
(( ${#body} > 6 )) || return 1
|
|
local hex
|
|
hex=$(awk -v data="$body" 'BEGIN {
|
|
charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
for (i = 0; i < 32; i++) map[substr(charset, i + 1, 1)] = i
|
|
data = substr(data, 1, length(data) - 6)
|
|
acc = 0; bits = 0; out = ""
|
|
for (i = 1; i <= length(data); i++) {
|
|
c = substr(data, i, 1)
|
|
if (!(c in map)) exit 1
|
|
acc = acc * 32 + map[c]
|
|
bits += 5
|
|
while (bits >= 8) {
|
|
bits -= 8
|
|
pow = 1
|
|
for (j = 0; j < bits; j++) pow *= 2
|
|
byte = int(acc / pow)
|
|
acc -= byte * pow
|
|
out = out sprintf("%02x", byte)
|
|
}
|
|
}
|
|
print out
|
|
}' 2>/dev/null) || return 1
|
|
# 32-byte x-only pubkey = 64 hex chars.
|
|
[[ ${#hex} -eq 64 ]] || return 1
|
|
printf '%s' "$hex"
|
|
}
|
|
|
|
# npub -> hex conversion. Tries `wn users show` first (handy when it works,
|
|
# since it also validates the npub against the daemon's view), then falls
|
|
# back to a local bech32 decode so unknown npubs still convert. Returns the
|
|
# input unchanged only if both paths fail, which should never happen for a
|
|
# well-formed npub.
|
|
npub_to_hex() {
|
|
local npub="$1" raw hex
|
|
raw=$(wn_b --json users show "$npub" 2>/dev/null || true)
|
|
hex=$(printf '%s' "$raw" \
|
|
| jq -r '(.result // .) | (.pubkey // .public_key // .hex // .[0]?.pubkey // .[0]?.public_key // empty)' \
|
|
2>/dev/null || true)
|
|
if [[ -z "$hex" || "$hex" == "null" ]]; then
|
|
# fallback to yaml-ish output
|
|
raw=$(wn_b users show "$npub" 2>/dev/null || true)
|
|
hex=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*\(public_key\|hex\)[[:space:]]*:[[:space:]]*//p' | head -n1)
|
|
fi
|
|
if [[ -n "$hex" && "$hex" != "null" ]]; then
|
|
printf '%s' "$hex"
|
|
return
|
|
fi
|
|
if hex=$(bech32_decode_npub "$npub"); then
|
|
printf '%s' "$hex"
|
|
return
|
|
fi
|
|
printf '%s' "$npub"
|
|
}
|
|
|
|
# ------- state file ----------------------------------------------------------
|
|
# Simple key=value store at $STATE_DIR/run.env
|
|
STATE_FILE_RUNTIME="${STATE_DIR}/run.env"
|
|
|
|
save_state() {
|
|
local key="$1" value="$2"
|
|
: >"${STATE_FILE_RUNTIME}.tmp"
|
|
if [[ -f "$STATE_FILE_RUNTIME" ]]; then
|
|
grep -v "^${key}=" "$STATE_FILE_RUNTIME" >>"${STATE_FILE_RUNTIME}.tmp" || true
|
|
fi
|
|
printf '%s=%s\n' "$key" "$value" >>"${STATE_FILE_RUNTIME}.tmp"
|
|
mv "${STATE_FILE_RUNTIME}.tmp" "$STATE_FILE_RUNTIME"
|
|
}
|
|
|
|
load_state() {
|
|
local key="$1"
|
|
[[ -f "$STATE_FILE_RUNTIME" ]] || return 1
|
|
grep "^${key}=" "$STATE_FILE_RUNTIME" 2>/dev/null | tail -n1 | cut -d= -f2-
|
|
}
|
|
|
|
# ------- daemon diagnostics --------------------------------------------------
|
|
# Dumps everything we know about a daemon's state. Called before polling for
|
|
# invites (baseline) and on every invite-poll failure so the operator can
|
|
# forward a single log file that explains what the daemon saw (or didn't).
|
|
#
|
|
# Output is tee'd to both stderr (for live viewing) and $LOG_FILE (for forward).
|
|
dump_daemon_diagnostics() {
|
|
local who="$1" tag="${2:-diagnostics}" data_dir socket wnfn npub hex
|
|
if [[ "$who" == "B" ]]; then
|
|
data_dir="$B_DIR"; socket="$B_SOCKET"; wnfn=wn_b
|
|
npub="${B_NPUB:-?}"; hex="${B_HEX:-?}"
|
|
else
|
|
data_dir="$C_DIR"; socket="$C_SOCKET"; wnfn=wn_c
|
|
npub="${C_NPUB:-?}"; hex="${C_HEX:-?}"
|
|
fi
|
|
|
|
printf '\n%s%s---- [%s] %s daemon diagnostics ----%s\n' \
|
|
"$C_BOLD" "$C_CYAN" "$tag" "$who" "$C_RESET" >&2
|
|
printf '\n==== [%s] %s daemon diagnostics ====\n' "$tag" "$who" >>"$LOG_FILE"
|
|
|
|
info "$who identity npub=$npub"
|
|
info "$who identity hex =$hex"
|
|
info "$who socket $socket"
|
|
|
|
# Relay list grouped by type — this is the critical bit for invite
|
|
# debugging. whitenoise-rs subscribes to kind:1059 on Inbox relays only,
|
|
# so if Amethyst doesn't see $B_NPUB's kind:10050 list pointing at these
|
|
# URLs, the gift wrap will never reach B.
|
|
step "$who relays (per type):"
|
|
for t in nip65 inbox key_package; do
|
|
local raw urls
|
|
raw=$("$wnfn" --json relays list --type "$t" 2>/dev/null || true)
|
|
urls=$(printf '%s' "$raw" \
|
|
| jq -r '(.result // .) | .[]? | (.url // .relay.url // empty)' 2>/dev/null \
|
|
| paste -sd',' -)
|
|
info " $t: ${urls:-<none>}"
|
|
printf ' %s %s raw: %s\n' "$who" "$t" "$raw" >>"$LOG_FILE"
|
|
done
|
|
|
|
# Connection status per relay — shows which sockets are actually live.
|
|
step "$who relay connection status:"
|
|
"$wnfn" --json relays list 2>/dev/null \
|
|
| jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \
|
|
| tee -a "$LOG_FILE" >&2 || true
|
|
|
|
# Daemon-level subscription health.
|
|
step "$who wn debug health:"
|
|
local health
|
|
health=$("$wnfn" --json debug health 2>/dev/null || "$wnfn" debug health 2>/dev/null || true)
|
|
printf ' %s\n' "${health:-<no output>}" | tee -a "$LOG_FILE" >&2
|
|
|
|
# Relay-control state (subscriptions, filters, planes). Truncated to keep
|
|
# stderr readable; full copy still goes to $LOG_FILE.
|
|
step "$who wn debug relay-control-state (truncated to stderr; full in $LOG_FILE):"
|
|
local rcs
|
|
rcs=$("$wnfn" --json debug relay-control-state 2>/dev/null || "$wnfn" debug relay-control-state 2>/dev/null || true)
|
|
printf '%s\n' "${rcs:-<no output>}" | head -n 20 | sed 's/^/ /' >&2
|
|
printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-<no output>}" >>"$LOG_FILE"
|
|
|
|
# Pending welcomes already decrypted by the daemon. Non-empty here while
|
|
# wait_for_invite is polling would be a race worth noticing.
|
|
step "$who pending invites:"
|
|
local inv
|
|
inv=$("$wnfn" --json groups invites 2>/dev/null || true)
|
|
printf ' %s\n' "${inv:-<no output>}" | tee -a "$LOG_FILE" >&2
|
|
|
|
# Tail of daemon stderr — contains the live 1059/welcome processing logs.
|
|
step "$who wnd stderr (last 80 lines from $data_dir/logs/stderr.log):"
|
|
if [[ -f "$data_dir/logs/stderr.log" ]]; then
|
|
tail -n 80 "$data_dir/logs/stderr.log" 2>/dev/null | sed 's/^/ /' | tee -a "$LOG_FILE" >&2 || true
|
|
else
|
|
info " (no stderr log at $data_dir/logs/stderr.log)"
|
|
fi
|
|
|
|
printf '%s%s---- end %s diagnostics ----%s\n\n' \
|
|
"$C_BOLD" "$C_CYAN" "$who" "$C_RESET" >&2
|
|
printf '==== end [%s] %s diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE"
|
|
}
|
|
|
|
# Asks the operator to capture Amethyst's MarmotDbg logcat so the script log
|
|
# ends up with both sides of the pipe in one place.
|
|
prompt_amethyst_logcat() {
|
|
local note="${1:-paste below}"
|
|
prompt_human "On the host running adb, capture Amethyst's Marmot logs:
|
|
|
|
adb logcat -d -v time | grep -E 'MarmotDbg|Marmot |MlsWelcome|GiftWrap' \\
|
|
| tail -n 200 > /tmp/amethyst-marmot.log
|
|
|
|
Then paste /tmp/amethyst-marmot.log contents here so the failure report has
|
|
both the Amethyst-side publish log and the wn-side subscription log ($note)."
|
|
}
|
|
|
|
# Dump everything we know about an outbound kind:445 from a wn daemon, so
|
|
# when the operator reports "Amethyst didn't see it" we can tell them which
|
|
# side of the wire to investigate:
|
|
#
|
|
# - `messages list` output (did the daemon persist the message locally,
|
|
# i.e. is this a relay-reach issue, a membership issue, or a pure
|
|
# Amethyst-receive issue?)
|
|
# - `groups show` metadata (which MLS-extension relays is wn using for
|
|
# this group — these are the SAME relays Amethyst subscribes to, so if
|
|
# the lists don't match that's the bug)
|
|
# - live relay connection status
|
|
#
|
|
# Output is tee'd to stderr (live) and $LOG_FILE (forward).
|
|
dump_outbound_diagnostics() {
|
|
local who="$1" gid="$2" needle="${3:-}" tag="${4:-post-send}"
|
|
local wnfn
|
|
if [[ "$who" == "B" ]]; then wnfn=wn_b
|
|
else wnfn=wn_c; fi
|
|
|
|
printf '\n%s%s---- [%s] %s outbound diagnostics (group %.8s…) ----%s\n' \
|
|
"$C_BOLD" "$C_CYAN" "$tag" "$who" "$gid" "$C_RESET" >&2
|
|
printf '\n==== [%s] %s outbound diagnostics (group %s) ====\n' "$tag" "$who" "$gid" >>"$LOG_FILE"
|
|
|
|
step "$who local view of latest messages in group"
|
|
local list_raw
|
|
list_raw=$("$wnfn" --json messages list "$gid" --limit 5 2>/dev/null || true)
|
|
printf ' %s\n' "${list_raw:-<no output>}" >>"$LOG_FILE"
|
|
printf '%s' "$list_raw" \
|
|
| jq -r '(.result // .) | .[]? | " \(.id // .event_id // "?") \((.content // .text // "") | tostring | .[0:80])"' 2>/dev/null \
|
|
| tee -a "$LOG_FILE" >&2 || true
|
|
if [[ -n "$needle" ]]; then
|
|
if printf '%s' "$list_raw" | jq -e --arg n "$needle" \
|
|
'(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \
|
|
>/dev/null 2>&1; then
|
|
info "$who local store contains \"$needle\" — send reached wn's own DB (kind:445 went out)"
|
|
else
|
|
warn "$who local store does NOT contain \"$needle\" — `messages send` never persisted; check stderr.log"
|
|
fi
|
|
fi
|
|
|
|
step "$who MLS group metadata (relays here are what Amethyst subscribes on)"
|
|
local meta_raw
|
|
meta_raw=$("$wnfn" --json groups show "$gid" 2>/dev/null || true)
|
|
printf ' %s\n' "${meta_raw:-<no output>}" >>"$LOG_FILE"
|
|
local meta_relays
|
|
meta_relays=$(printf '%s' "$meta_raw" \
|
|
| jq -r '(.result // .) | (.relays // .group.relays // [])[]? | (. | tostring)' 2>/dev/null \
|
|
| paste -sd',' -)
|
|
info " group relays (MLS extension): ${meta_relays:-<none — Amethyst will fall back to homeRelays>}"
|
|
local meta_name meta_epoch
|
|
meta_name=$(printf '%s' "$meta_raw" | jq -r '(.result // .) | (.name // .group.name // "?")' 2>/dev/null)
|
|
meta_epoch=$(printf '%s' "$meta_raw" | jq -r '(.result // .) | (.epoch // .group.epoch // "?")' 2>/dev/null)
|
|
info " group name: $meta_name epoch: $meta_epoch"
|
|
|
|
step "$who relay connection status right now"
|
|
"$wnfn" --json relays list 2>/dev/null \
|
|
| jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \
|
|
| tee -a "$LOG_FILE" >&2 || true
|
|
|
|
printf '%s%s---- end outbound diagnostics ----%s\n\n' \
|
|
"$C_BOLD" "$C_CYAN" "$C_RESET" >&2
|
|
printf '==== end [%s] %s outbound diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE"
|
|
}
|
|
|
|
# ------- group cleanup -------------------------------------------------------
|
|
cleanup_group() {
|
|
local gid="$1" who="${2:-B}"
|
|
[[ -z "$gid" ]] && return 0
|
|
if [[ "$who" == "B" ]]; then
|
|
wn_b groups leave "$gid" >/dev/null 2>&1 || true
|
|
else
|
|
wn_c groups leave "$gid" >/dev/null 2>&1 || true
|
|
fi
|
|
}
|