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

200 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 在环上传的东西:
```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`),全流程不触碰生产。