feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs

- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix)
- Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope)
- Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels)
- API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints
- Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference)
- Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes
- Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
2026-08-19 21:09:24 +08:00
parent b518a13446
commit eda9bb9597
55 changed files with 5462 additions and 793 deletions

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# w4f demo cluster: node-a seeds frpc cache, node-b/c auto-pull from neighbors (M6).
# w4f demo cluster: 4-node ring (a=leader, b/c/d join) + 1 isolated standalone (e).
set -euo pipefail
cd "$(dirname "$0")"
@ -10,7 +10,7 @@ VER=0.70.1
docker network inspect $NET >/dev/null 2>&1 || docker network create $NET
# clean + seed node-a cache with real frpc binary
for d in node-a node-b node-c; do rm -rf data/$d 2>/dev/null || true; mkdir -p data/$d; done
for d in node-a node-b node-c node-d node-e; do rm -rf data/$d 2>/dev/null || true; mkdir -p data/$d; done
mkdir -p data/node-a/bin/frpc-$VER
[ -x data/node-a/bin/frpc-$VER/frpc ] || cp $FRP/frpc data/node-a/bin/frpc-$VER/frpc
chmod +x data/node-a/bin/frpc-$VER/frpc
@ -30,11 +30,31 @@ docker run -d --name w4f-frps3 --user $(id -u):$(id -g) --network $NET --network
echo "== starting node-a (seed, has frpc cache) =="
docker run -d --name w4f-node-a --user $(id -u):$(id -g) --network $NET --network-alias node-a -e W4F_HOST=node-a -p 7501:7500 -p 17401:7400 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-a:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc
echo "== starting node-b (no frpc; peer node-a) =="
docker run -d --name w4f-node-b --user $(id -u):$(id -g) --network $NET --network-alias node-b -e W4F_HOST=node-b -p 7502:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-b:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc -peer=node-a:7500
# Wait for node-a to be ready and fetch its admission key. Nodes b/c/d join
# via node-a, so they need node-a's nodeKey (-join-key) to pass the sponsor
# verification in handleClusterJoin (403 on mismatch/empty). Node-e is isolated.
echo "== fetching node-a join key =="
KEY=""
for _ in $(seq 1 15); do
KEY=$(curl -fsS -u admin:admin123 http://localhost:7501/api/manager/cluster/ring 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('nodeKey',''))" 2>/dev/null || true)
[ -n "$KEY" ] && break
sleep 1
done
if [ -z "$KEY" ]; then echo "FATAL: could not fetch node-a join key"; docker logs w4f-node-a 2>&1 | tail 20; exit 1; fi
echo " node-a join key: $KEY"
echo "== starting node-c (no frpc; peer node-b) =="
docker run -d --name w4f-node-c --user $(id -u):$(id -g) --network $NET --network-alias node-c -e W4F_HOST=node-c -p 7503:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-c:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc -peer=node-b:7500
echo "== starting node-b (no frpc; joins via node-a) =="
docker run -d --name w4f-node-b --user $(id -u):$(id -g) --network $NET --network-alias node-b -e W4F_HOST=node-b -p 7502:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-b:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc -peer=node-a:7500 -join-key=$KEY
echo "== starting node-c (no frpc; joins via node-a) =="
docker run -d --name w4f-node-c --user $(id -u):$(id -g) --network $NET --network-alias node-c -e W4F_HOST=node-c -p 7503:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-c:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc -peer=node-a:7500 -join-key=$KEY
echo "== starting node-d (no frpc; joins via node-a) =="
docker run -d --name w4f-node-d --user $(id -u):$(id -g) --network $NET --network-alias node-d -e W4F_HOST=node-d -p 7504:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-d:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc -peer=node-a:7500 -join-key=$KEY
echo "== starting node-e (isolated standalone, not in cluster) =="
docker run -d --name w4f-node-e --user $(id -u):$(id -g) --network $NET --network-alias node-e -e W4F_HOST=node-e -p 7505:7500 -v $PWD/artifacts/webui4frpc:/app/webui4frpc:ro -v $PWD/data/node-e:/data -v $FRP:/app/frp:ro --restart unless-stopped --entrypoint /app/webui4frpc debian:bookworm-slim -addr=0.0.0.0:7500 -user=admin -password=admin123 -workdir=/data -frpc=/app/frp/frpc
echo "== cluster up =="
docker ps --filter "name=w4f-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"

View File

@ -20,7 +20,9 @@ AUTH=admin:admin123
A=http://localhost:7501
B=http://localhost:7502
C=http://localhost:7503
NODE_PORTS=("$A" "$B" "$C")
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
@ -46,11 +48,31 @@ if ! docker ps --format '{{.Names}}' | grep -q '^w4f-node-a$'; then
fi
bash "$PWD/start-mock-frps.sh" >/dev/null 2>&1 || true
# ---- 1. confirm ring has 3 nodes ----
echo "[1] cluster ring health"
RING=$(cget "$A" /cluster/ring)
NN=$(py 'len(d.get("nodes",[]))' <<< "$RING")
if [ "$NN" -ge 3 ]; then ok "ring has $NN nodes"; else fail "ring has $NN nodes (expected ≥3)"; exit 1; fi
# ---- 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"