Files
webui4frpc/docs/how-it-works.md
JianFeeeee 2dd32648e6 docs: 补充内部机制说明与虚拟 frps 复现脚本
与本次 leader 修复无关,是工作区里此前遗留未提交的两个文件,误被 git add -A
卷进上一个提交,此处独立成提交。docs/how-it-works.md 记录了 DB 是唯一真源、
集群只复制环状态不复制配置、forward owner 归属等实测结论;配
scripts/probe-with-virtual-frps.sh 可在隔离环境复现全部结论,不触碰生产。
2026-10-05 17:02:41 +08:00

7.0 KiB
Raw Blame History

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/<name>.json     ← 派生产物
      ↓ exec: frpc -c <cfg>
  → frpc worker 进程

推论(已在生产验证):手改 configs/*.json 会被下一次 DB 写回覆盖。 配置变更必须走 API(PUT /api/manager/canvas 或 /remotes)。

configs/ 目录的文件名格式为 <local>~<remote>~<remotePort>.json。 只有实跑的那一份会被 frpc -c 引用;同名不同端口的历史文件会残留。


2. 集群不复制配置,只复制环状态

internal/cluster/token_transport.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):

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):

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 的解析顺序:

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 日志里)。排障时先查:

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:

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. 复现

scripts/probe-with-virtual-frps.sh

搭建 frps 0.61.1(bindPort=17000 / vhostHTTPPort=17080 / vhostHTTPsPort=17443, 独立 token),用独立 workdir 拉起 webui4frpc(:17500),全流程不触碰生产。