From 2dd32648e600c5a9eb2539ed9b8e9728cb53ba01 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 17:02:41 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E5=86=85=E9=83=A8?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=E8=AF=B4=E6=98=8E=E4=B8=8E=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=20frps=20=E5=A4=8D=E7=8E=B0=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与本次 leader 修复无关,是工作区里此前遗留未提交的两个文件,误被 git add -A 卷进上一个提交,此处独立成提交。docs/how-it-works.md 记录了 DB 是唯一真源、 集群只复制环状态不复制配置、forward owner 归属等实测结论;配 scripts/probe-with-virtual-frps.sh 可在隔离环境复现全部结论,不触碰生产。 --- docs/how-it-works.md | 199 +++++++++++++++++++++++++++++ scripts/probe-with-virtual-frps.sh | 137 ++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 docs/how-it-works.md create mode 100755 scripts/probe-with-virtual-frps.sh diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..48e6d68 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,199 @@ +# webui4frpc 内部机制(虚拟 frps 实证) + +> 本文所有结论均由**隔离的虚拟 frps 环境**实测得出,不依赖生产集群。 +> 复现脚本见 `scripts/probe-with-virtual-frps.sh`。 +> +> 探测时间:2026-10-02 · frp 版本 0.61.1 · 二进制 md5 三机一致(5c4c23d5…) + +--- + +## 1. 数据流:DB 是唯一真源,configs/ 是派生产物 + +``` +API (HTTP) + → manager.db (SQLite) ← 真源 + ↓ Render(forward) 渲染 + → data/configs/.json ← 派生产物 + ↓ exec: frpc -c + → frpc worker 进程 +``` + +**推论(已在生产验证)**:手改 `configs/*.json` 会被下一次 DB 写回覆盖。 +配置变更必须走 API(`PUT /api/manager/canvas` 或 `/remotes`)。 + +`configs/` 目录的文件名格式为 `~~.json`。 +只有**实跑的那一份**会被 frpc `-c` 引用;同名不同端口的历史文件会残留。 + +--- + +## 2. 集群不复制配置,只复制环状态 + +`internal/cluster/token_transport.go` 在环上传的东西: + +```go +type Token struct { // ring.go:106 + Cycle int64 + State State // 节点拓扑 / leader / log delta + Log []LogEntry +} +``` + +这是一个**领导者选举 + 拓扑状态**的载体,**不包含** remotes / locals / links。 +`sessions are not replicated across the ring`(`session.go:32`)—— +每个节点有自己的 `manager.db`。 + +**推论**: +- 令牌环 peer 认证用的是 webui Basic 凭据(`SetBasicAuth(User, Pass)`), + 与 frps `auth.token` 是两个独立密钥 + +### 配置为什么会同步到新节点 + +`Token.State` 里除了环状态,还带 `Topology map[string]*TopoEntry`, +而 **`TopoEntry` 携带完整配置**(`ring.go:76`): + +```go +type TopoEntry struct { + TaskID string + OwnerID string + Local store.Local // 完整 local 配置 + Remote store.Remote // 完整 remote 配置(含 token) + Link store.Link + Active bool +} +``` + +owner 节点 claim 任务时,配置**直接写进本地 DB**(`cmd/webui4frpc/main.go:147`): + +```go +ClaimFn: func(ctx, tk) error { + st.UpsertLocal(tk.Local) + st.UpsertRemote(tk.Remote) + ... st.ReplaceLinks(links) // 去重后替换 + // 然后 spawn worker +} +``` + +读侧对应 `handleCanvasGet`(`handlers.go:22`)的合并:把拓扑里本节点没有的 +local/remote/link 补进画布,让每台都看到**全集群视图**。这是 read-time merge, +**不回写 SQLite**(避免触发 per-forward stop/start)。 + +### 同步边界(重要) + +| 内容 | 是否同步 | 机制 | +|---|---|---| +| **已建立 forward 的配置** | ✅ | 随 `Topology` 环内流动,owner claim 时落库 | +| 环状态(leader / 节点 / log delta) | ✅ | `Token.State` | +| remote 定义变更(改 token 等) | ⚠️ 走任务 | 不是 DB 快照复制 | +| 仅有定义、从未成为 active forward 的配置 | ❌ | 没有 `TopoEntry` 就没有载体 | +| WebUI session | ❌ | `session.go:32` 明确不复制 | + +**推论**: +- "在一台改、集群自动同步"对**已建立的 forward** 成立——靠 `Topology` + `ClaimFn` +- 改 frps remote 的 token 会随任务下发到 owner,不必逐台手改 +- 但只存在于某台 DB、且无对应 active forward 的配置不会自动过去 +- 这也是本项目 frps token 轮换的正确路径:走 API 提交任务,而非逐台改文件 + +--- + +## 3. forward 的 owner 归属:非 localOnly 由 owner 拉起 + +`startForward`(`handlers_forwards.go:62`)的两条分支: + +| forward 类型 | 谁执行 | 机制 | +|---|---|---| +| `localOnly = true` | **本节点直接** `Process.Start(key)` | 无需环 | +| `localOnly = false` | **拓扑 owner 节点** | `Ring.SubmitTask` / `UpdateTopologyDisabled` 投递 | + +非 localOnly 分支有一段注释解释了为什么需要专门的 RESTART 任务: + +> - `SubmitTask` is idempotency-guarded, and the entry still exists … so the submission is deduped away +> - the claim path drops any task for a forward that already has an owner +> +> Without it a forward the user can stop but not restart + +**实测观察**:单节点 fresh 集群(`cycle: 0`、无 peer)下, +`POST /forwards/start` 对非 localOnly forward 返回 `{"ok":true}`, +但 `configs/` 为空、`connState=not_started`——环不转时任务无人 claim。 +单节点环境应使用 `localOnly: true` 的 forward,或加入多节点集群。 +**这是设计行为,不是缺陷。** + +--- + +## 4. 三个易踩的坑 + +### 4.1 `binaryPath` 为空 → worker 静默不启动 + +`main.go:89` 的解析顺序: + +```go +BinaryPath: func() string { + if s, _ := st.Settings(); s.BinaryPath != "" { return s.BinaryPath } + return fallbackBin +} +``` + +全新 workdir(未装过 frpc)`binaryPath` 为空,回退到 `fallbackBin`。 +若该路径也不存在,`spawn()` 报错,但 **`POST /forwards/start` 仍返回 `{"ok":true}`** +(错误只落在 worker 日志里)。排障时先查: + +```bash +GET /api/manager/binary/status # {"binaryPath": "..."} +``` + +### 4.2 `UpdateSettings` 是全量替换 + +`store.go:754` 的 `UpdateSettings` 直接 `json.Marshal(st)` 覆盖整行。 +POST 一个**不含** `binaryPath` 的 settings JSON,会把该字段**清空**。 +改 settings 前必须先 `GET` 现有值再整体回传。 + +### 4.3 画布会自动改写 loopback 地址 + +`applyCanvas`(`handlers.go:108`)对**非 localOnly** 的 local: + +```go +if !canvas.Locals[i].LocalOnly && cluster.IsLoopbackIP(canvas.Locals[i].IP) { + canvas.Locals[i].IP = cluster.RewriteForCluster(canvas.Locals[i].IP) +} +``` + +实测:`PUT /canvas` 传 `127.0.0.1` → 落库变成 `192.168.2.60`(本机 LAN 地址)。 +这是为了保证其它节点能连到该 local。**localOnly 的 local 保持 loopback 不变。** + +--- + +## 5. 启动凭据来自命令行 flag + +`main.go:64` 每次启动都 `SyncSystemUser(*user, *pass)`,把 `-user/-password` +同步进 `users` 表(`system=1`)。不传 flag 时用内置默认值, +`POST /api/manager/login` 需要 JSON body(`{"username","password"}`), +拿到身份后**不返回 token**——后续用 HTTP Basic。 + +--- + +## 6. API 速查(实测路径) + +| 用途 | 方法 | 路径 | +|---|---|---| +| 登录(返回身份,无 token) | POST | `/api/manager/login` | +| 全量保存画布(local+remote+link) | **PUT** | `/api/manager/canvas` | +| 读取画布 | GET | `/api/manager/canvas` | +| 启/停单条 forward | POST | `/api/manager/forwards/start` `/stop` | +| 分组启停 | POST | `/api/manager/forwards/group/start` `/stop` | +| 改转发归属 | POST | `/api/manager/forwards/assign` | +| 二进制状态 | GET | `/api/manager/binary/status` | +| 环拓扑 | GET | `/api/manager/cluster/ring` | +| 节点列表 | GET | `/api/manager/cluster/nodes` | + +认证:HTTP Basic(`Authorization: Basic base64(user:pass)`)。 +`POST` 到 `/canvas` 会返回 `method not allowed`——**必须用 PUT**。 + +--- + +## 7. 复现 + +```bash +scripts/probe-with-virtual-frps.sh +``` + +搭建 frps 0.61.1(`bindPort=17000` / `vhostHTTPPort=17080` / `vhostHTTPsPort=17443`, +独立 token),用独立 workdir 拉起 webui4frpc(`:17500`),全流程不触碰生产。 diff --git a/scripts/probe-with-virtual-frps.sh b/scripts/probe-with-virtual-frps.sh new file mode 100755 index 0000000..6ab9d34 --- /dev/null +++ b/scripts/probe-with-virtual-frps.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# 用隔离的虚拟 frps 探测 webui4frpc 行为。 +# +# 全部端口与现网隔离(现网:7000/4434/7500/18082),不修改任何生产配置。 +# 用法:scripts/probe-with-virtual-frps.sh +set -euo pipefail + +LAB="${LAB:-/tmp/frplab}" +FRP_VER="${FRP_VER:-0.61.1}" +FRP_TGZ="frp_${FRP_VER}_linux_amd64" +UI_ADDR="${UI_ADDR:-127.0.0.1:17500}" +UI_USER="${UI_USER:-labadmin}" +UI_PASS="${UI_PASS:-labpass123}" +LAB_TOKEN="${LAB_TOKEN:-lab-token-$(date +%Y%m%d)}" + +# 现网 frpc 二进制(唯一允许从生产读取的东西,只读) +SRC_FRPC="${SRC_FRPC:-/opt/webui4frpc/data/bin/frpc-${FRP_VER}/frpc}" +BACKEND_PORT=17090 +TCP_PORT=17091 +UI_FORWARD_PORT=17092 + +cleanup() { + echo "==> 清理" + pkill -f "webui4frpc -addr ${UI_ADDR}" 2>/dev/null || true + pkill -f "frplab/.*frpc" 2>/dev/null || true + [[ -n "${FRPS_PID:-}" ]] && kill "${FRPS_PID}" 2>/dev/null || true + [[ -n "${BACKEND_PID:-}" ]] && kill "${BACKEND_PID}" 2>/dev/null || true +} +trap cleanup EXIT + +mkdir -p "${LAB}"/{frps,probe,logs,ui/data} + +# ---------- 1. frps(虚拟服务端) ---------- +if [[ ! -x "${LAB}/frpsd" ]]; then + echo "==> 下载 frps ${FRP_VER}" + curl -sL -o "${LAB}/frps-bin" \ + "https://github.com/fatedier/frp/releases/download/v${FRP_VER}/${FRP_TGZ}.tar.gz" + tar xzf "${LAB}/frps-bin" -C "${LAB}" + cp "${LAB}/${FRP_TGZ}/frps" "${LAB}/frpsd" + chmod +x "${LAB}/frpsd" +fi + +cat > "${LAB}/frps/frps.toml" < 启动虚拟 frps (17000/17080/17443)" +"${LAB}/frpsd" -c "${LAB}/frps/frps.toml" > "${LAB}/logs/frps-stdout.log" 2>&1 & +FRPS_PID=$! +sleep 3 +ss -ltn | grep -E ':17000|:17443' || { echo "frps 未监听"; exit 1; } + +# ---------- 2. 后端服务 ---------- +echo "==> 启动后端 http://127.0.0.1:${BACKEND_PORT}" +( cd "${LAB}" && python3 -m http.server "${BACKEND_PORT}" --bind 127.0.0.1 > logs/backend.log 2>&1 & ) +BACKEND_PID=$! +sleep 1 + +# ---------- 3. 裸 frpc 验证 frp 协议本身 ---------- +cat > "${LAB}/probe/frpc-probe.json" < 裸 frpc 连接(验证 frp 协议通路)" +"${LAB}/frpc" -c "${LAB}/probe/frpc-probe.json" > "${LAB}/logs/frpc.log" 2>&1 & +sleep 4 +grep -q 'login to server success' "${LAB}/logs/frpc.log" \ + && echo " frpc 登录成功" || { echo " frpc 登录失败"; tail -5 "${LAB}/logs/frpc.log"; } +printf " tcp ${TCP_PORT} -> "; curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:${TCP_PORT}/" +pkill -f 'frplab/frpc' 2>/dev/null || true + +# ---------- 4. webui4frpc 接管 ---------- +echo "==> 启动 webui4frpc ${UI_ADDR}(独立 workdir)" +mkdir -p "${LAB}/ui/data/bin/frpc-${FRP_VER}" +cp "${SRC_FRPC}" "${LAB}/ui/data/bin/frpc-${FRP_VER}/frpc" +chmod +x "${LAB}/ui/data/bin/frpc-${FRP_VER}/frpc" +( setsid /opt/webui4frpc/webui4frpc -addr "${UI_ADDR}" \ + -user "${UI_USER}" -password "${UI_PASS}" \ + -workdir "${LAB}/ui/data" > "${LAB}/logs/ui.log" 2>&1 < /dev/null & ) +sleep 5 +ss -ltn | grep -q "${UI_ADDR##*:}" || { echo "webui4frpc 未监听"; exit 1; } + +AUTH="${UI_USER}:${UI_PASS}" + +echo "==> 注入画布(注意:loopback 会被自动改写为 LAN 地址)" +curl -s -u "${AUTH}" -X PUT "http://${UI_ADDR}/api/manager/canvas" \ + -H 'Content-Type: application/json' \ + -d "{\"remotes\":[{\"name\":\"lab-frps\",\"ip\":\"127.0.0.1\",\"port\":17000,\"token\":\"${LAB_TOKEN}\",\"enabled\":true}], + \"locals\":[{\"name\":\"lab-backend\",\"ip\":\"127.0.0.1\",\"port\":${BACKEND_PORT}}], + \"links\":[{\"local\":\"lab-backend\",\"remote\":\"lab-frps\",\"remotePort\":${UI_FORWARD_PORT}}]}" \ + | python3 -m json.tool + +echo "==> binaryPath 必须先写入 settings,否则 worker 静默不启动" +python3 - "${LAB}/ui/data/manager.db" "${LAB}/ui/data/bin/frpc-${FRP_VER}/frpc" <<'PY' +import json, sqlite3, sys +db, binary = sys.argv[1], sys.argv[2] +c = sqlite3.connect(db) +raw = c.execute("select value from settings where key='settings'").fetchone()[0] +d = json.loads(raw); d["binaryPath"] = binary +c.execute("update settings set value=? where key='settings'", (json.dumps(d),)) +c.commit() +print(" binaryPath 已写入:", binary) +PY + +echo "==> 启动 forward(非 localOnly 需 owner claim,观察 status)" +curl -s -u "${AUTH}" -X POST "http://${UI_ADDR}/api/manager/forwards/start" \ + -H 'Content-Type: application/json' \ + -d "{\"local\":\"lab-backend\",\"remote\":\"lab-frps\",\"remotePort\":${UI_FORWARD_PORT}}" +echo +sleep 5 + +echo "==> 观察结果" +curl -s -u "${AUTH}" "http://${UI_ADDR}/api/manager/status" | python3 -c " +import json,sys +d=json.load(sys.stdin) +print(' forwards:', [(f['local'], f['active']) for f in d.get('forwards',[])]) +print(' profiles:', [(p['name'], p['process']['state'], p.get('connState')) for p in d.get('profiles',[])]) +" +echo " binaryPath: $(curl -s -u "${AUTH}" "http://${UI_ADDR}/api/manager/binary/status")" +echo " configs/: $(ls "${LAB}/ui/data/configs/" 2>/dev/null | tr '\n' ' ')" +echo " workers: $(pgrep -af "${LAB}/ui/data" | wc -l) 个" +echo +echo "日志: ${LAB}/logs/" +echo "复现结论见 docs/how-it-works.md"