#!/usr/bin/env bash # test-forward.sh — exercise the token-ring forwarding dispatch pipeline. # # Submits 4 forwards via PUT /canvas (localOnly=false → ring tasks): # r-frps → frps:7000 (real, expect connected) # r-frps2 → frps2:7001 (real mock, expect connected) # r-frps3 → frps3:7002 (real mock, expect connected) # r-dead → 192.0.2.1 (dead addr, expect dispatch+worker-start, conn failed) # # Verifies: pending→0, topology claimed (with ownerId), worker running on the # claiming node, frpc "login to server success" for real targets, dead target # worker running but conn failed. Then revokes (restore canvas) and verifies # topology cleared + worker stopped. # # Run after the cluster is up: bash deploy/test-forward.sh set -euo pipefail cd "$(dirname "$0")" AUTH=admin:admin123 A=http://localhost:7501 B=http://localhost:7502 C=http://localhost:7503 D=http://localhost:7504 E=http://localhost:7505 NODE_PORTS=("$A" "$B" "$C" "$D" "$E") RESULTS=$(mktemp -t w4f-results.XXXX) trap 'rm -f "$RESULTS"' EXIT # ---- helpers ---- cget() { curl -s -u "$AUTH" "$1/api/manager$2"; } cput() { curl -s -X PUT -u "$AUTH" -H 'Content-Type: application/json' --data "$3" "$1/api/manager$2"; } cpost() { curl -s -X POST -u "$AUTH" -H 'Content-Type: application/json' --data "$3" "$1/api/manager$2"; } # py '' reads stdin json as d; stmt may use d py() { python3 -c "import sys,json; d=json.load(sys.stdin); print($1)"; } ok() { printf " \033[32m✓\033[0m %s\n" "$1"; } fail() { printf " \033[31m✗\033[0m %s\n" "$1"; } info() { printf " · %s\n" "$1"; } echo "╭──────────────────────────────────────────────╮" echo "│ w4f forwarding dispatch pipeline test │" echo "╰──────────────────────────────────────────────╯" # ---- preflight: cluster + mock frps up ---- if ! docker ps --format '{{.Names}}' | grep -q '^w4f-node-a$'; then fail "w4f-node-a not running — start the cluster first: bash deploy/run-cluster.sh" exit 1 fi bash "$PWD/start-mock-frps.sh" >/dev/null 2>&1 || true # ---- 1. confirm ring has 5 nodes (wait for convergence) ---- echo "[1] cluster ring health (expect 4 nodes)" deadline=$(( $(date +%s) + 30 )) NN=0 while [ $(date +%s) -lt $deadline ]; do RING=$(cget "$A" /cluster/ring) NN=$(py 'len(d.get("nodes",[]))' <<< "$RING") if [ "$NN" -ge 4 ]; then break; fi sleep 1 done if [ "$NN" -ge 4 ]; then ok "ring has $NN nodes"; else fail "ring has $NN nodes (expected ≥4)"; exit 1; fi # ---- 1.5 join key security (wrong key → 403, correct key present) ---- echo "[1.5] join key security" KEY=$(py 'd.get("nodeKey","")' <<< "$RING") if [ -n "$KEY" ]; then ok "nodeKey present in snapshot (${KEY:0:8}…)"; else fail "no nodeKey in ring snapshot"; fi CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST -u "$AUTH" -H 'Content-Type: application/json' \ --data '{"id":"evil","addr":"evil:7500","version":"0.1.0","joinKey":"wrong-key"}' \ "$A/api/manager/cluster/join") if [ "$CODE" = "403" ]; then ok "wrong join key → 403 (rejected)"; else fail "wrong join key → HTTP $CODE (expected 403)"; fi # empty key → 403 too CODE2=$(curl -s -o /dev/null -w "%{http_code}" -X POST -u "$AUTH" -H 'Content-Type: application/json' \ --data '{"id":"evil","addr":"evil:7500","version":"0.1.0","joinKey":""}' \ "$A/api/manager/cluster/join") if [ "$CODE2" = "403" ]; then ok "empty join key → 403 (rejected)"; else fail "empty join key → HTTP $CODE2 (expected 403)"; fi # ---- 2. snapshot original canvas (for cleanup/restore) ---- echo "[2] snapshot canvas" ORIG=$(cget "$A" /canvas) CNT=$(echo "$ORIG" | py 'len((d.get("locals") or [])) + len((d.get("remotes") or [])) + len((d.get("links") or []))') info "original canvas: $CNT entries (locals+remotes+links)" # ---- 3. build merged canvas with 4 forwards + PUT ---- echo "[3] submit 4 forwards (PUT /canvas on node-a)" MERGED=$(python3 -c ' import json, sys orig = json.load(sys.stdin) locals_ = orig.get("locals", []) or [] remotes = orig.get("remotes", []) or [] links = orig.get("links", []) or [] # de-dup by name so re-runs are idempotent have_l = {l["name"] for l in locals_} have_r = {r["name"] for r in remotes} if "backend-svc" not in have_l: locals_.append({"name":"backend-svc","ip":"backend","port":8080,"protocol":"tcp","localOnly":False}) new_rems = [ ("r-frps", "frps", 7000), ("r-frps2", "frps2", 7001), ("r-frps3", "frps3", 7002), ("r-dead", "192.0.2.1", 7000), ] for nm, ip, port in new_rems: if nm not in have_r: remotes.append({"name":nm,"ip":ip,"port":port,"token":"demo-token","enabled":True}) ports = {"r-frps":18091,"r-frps2":18092,"r-frps3":18093,"r-dead":18094} have_link = {(l["local"],l["remote"]) for l in links} for nm in ports: key = ("backend-svc", nm) if key not in have_link: links.append({"local":"backend-svc","remote":nm,"remotePort":ports[nm]}) print(json.dumps({"locals":locals_,"remotes":remotes,"links":links})) ' <<< "$ORIG") cput "$A" /canvas "$MERGED" >/dev/null ok "canvas submitted (1 local → 4 remotes, localOnly=false)" # the 4 remotes we expect to see claimed declare -A EXPECT_PORT=( [r-frps]=18091 [r-frps2]=18092 [r-frps3]=18093 [r-dead]=18094 ) declare -A EXPECT_CONN=( [r-frps]=connected [r-frps2]=connected [r-frps3]=connected [r-dead]=failed ) declare -A OWNER= # remote name -> node port URL # ---- 4. poll ring: pending → 0 + topology has our 4 ---- echo "[4] wait for dispatch: pending → claimed into topology" deadline=$(( $(date +%s) + 45 )) claimed=0 while [ $(date +%s) -lt $deadline ]; do RING=$(cget "$A" /cluster/ring) pend=$(py 'len(d.get("pending",[]))' <<< "$RING") # count our remotes present in topology (python — robust vs empty arrays) cnt=$(py 'sum(1 for t in d.get("topology",[]) if t["remote"]["name"] in ("r-frps","r-frps2","r-frps3","r-dead"))' <<< "$RING") if [ "$cnt" -ge 4 ]; then ok "all 4 in topology (pending=$pend)"; claimed=1; break; fi sleep 1 done if [ "$claimed" -ne 1 ]; then fail "only $cnt/4 reached topology after 45s (pending=$pend)" RING=$(cget "$A" /cluster/ring); info "ring: $(py 'json.dumps({"pending":len(d.get("pending",[])),"topo":[t["remote"]["name"] for t in d.get("topology",[])]})' <<< "$RING")" exit 1 fi info "topology: $(cget "$A" /cluster/ring | py '",".join(t["remote"]["name"]+"@"+t["ownerId"][:8] for t in d.get("topology",[]))')" # ---- 5. find owner node per remote + assert worker running ---- echo "[5] locate claiming node + assert worker running" for nm in r-frps r-frps2 r-frps3 r-dead; do found="" deadline=$(( $(date +%s) + 40 )) while [ $(date +%s) -lt $deadline ]; do for P in "${NODE_PORTS[@]}"; do ST=$(cget "$P" /status) # does this node have a profile named $nm with process.state=running? run=$(py 'next((1 for p in d.get("profiles",[]) if p["name"]=="'"$nm"'" and p.get("process",{}).get("state")=="running"),0)' <<< "$ST") if [ "$run" = "1" ]; then found="$P"; break; fi done [ -n "$found" ] && break sleep 1 done if [ -n "$found" ]; then OWNER[$nm]="$found" ok "$nm → worker running on $(echo "$found" | sed 's#http://localhost:##')" else fail "$nm → no running worker found on any node within 40s" OWNER[$nm]="" fi done # ---- 6. connection state + frpc login log ---- echo "[6] connection state + frpc login log" for nm in r-frps r-frps2 r-frps3 r-dead; do P="${OWNER[$nm]}" [ -z "$P" ] && { fail "$nm skipped (no worker)"; continue; } ST=$(cget "$P" /status) conn=$(py 'next((p.get("connState","") for p in d.get("profiles",[]) if p["name"]=="'"$nm"'"),"")' <<< "$ST") expect="${EXPECT_CONN[$nm]}" if [ "$nm" = "r-dead" ]; then # dead: worker running, conn NOT connected if [ "$conn" != "connected" ]; then ok "$nm connState=$conn (dead, not connected — correct)"; else fail "$nm unexpectedly connected"; fi else # real: wait for connected + login log deadline=$(( $(date +%s) + 40 )) logged="" while [ $(date +%s) -lt $deadline ]; do LOGS=$(cget "$P" /profiles/$nm/logs) if echo "$LOGS" | grep -q "login to server success"; then logged=1; break; fi sleep 1 done if [ -n "$logged" ]; then ok "$nm frpc logged in (login to server success)"; else fail "$nm no login-success in 40s (connState=$conn)"; fi fi done # ---- 6.5 tunnel data path for real frps (bonus) ---- echo "[6.5] tunnel data path (real frps → backend)" for nm in r-frps r-frps2 r-frps3; do port="${EXPECT_PORT[$nm]}" # curl the remotePort from inside the node-a container (same docker net) body=$(docker exec w4f-node-a sh -c "command -v curl >/dev/null 2>&1 && curl -sS --max-time 4 http://frps$([ "$nm" != "r-frps" ] && echo "2" || echo ""):$port 2>/dev/null || wget -qO- --timeout=4 http://frps$([ "$nm" != "r-frps" ] && echo "2" || echo ""):$port 2>/dev/null" 2>/dev/null || true) # the backend responds with some body; just check non-empty or a hit if [ -n "$body" ]; then ok "$nm tunnel :$port → backend returned data"; else info "$nm tunnel :$port no body (may be expected if backend gives empty)"; fi done # ---- 7. revoke (restore original canvas) ---- echo "[7] revoke: restore original canvas → wait for topology cleared" cput "$A" /canvas "$ORIG" >/dev/null ok "canvas restored to original snapshot" deadline=$(( $(date +%s) + 30 )) cleared=0 while [ $(date +%s) -lt $deadline ]; do RING=$(cget "$A" /cluster/ring) cnt=$(py 'sum(1 for t in d.get("topology",[]) if t["remote"]["name"] in ("r-frps","r-frps2","r-frps3","r-dead"))' <<< "$RING") if [ "$cnt" -eq 0 ]; then ok "topology cleared of all 4 forwards"; cleared=1; break; fi sleep 1 done [ "$cleared" -ne 1 ] && fail "topology still has $cnt forwards after 30s" # assert workers stopped on owners for nm in r-frps r-frps2 r-frps3 r-dead; do P="${OWNER[$nm]}"; [ -z "$P" ] && continue ST=$(cget "$P" /status) run=$(py 'next((1 for p in d.get("profiles",[]) if p["name"]=="'"$nm"'" and p.get("process",{}).get("state")=="running"),0)' <<< "$ST") if [ "$run" = "0" ]; then ok "$nm worker stopped on $(echo "$P" | sed 's#http://localhost:##')"; else fail "$nm worker still running after revoke"; fi done # ---- summary ---- echo "" echo "╭── result ──╮" echo "│ dispatch + claim + worker + connected : verified (3 real frps + 1 dead) " echo "│ revoke (canvas restore) → topology cleared + workers stopped : verified " echo "╰──────────────────────────────────────────────────────────────────────────╯"