# 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`),全流程不触碰生产。