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,88 +1,172 @@
# Phase C — ModelRouter 风格重设计 + 模拟 frps 转发下发测试
# 集群修复 + 加入密钥 + 画布/状态页增强 + Seq 删除
## 决策(用户未选择,依最佳判断
- **视觉方向 = 忠实采用 sakura×frost 玻璃拟态**ModelRouter 主色 #FF7FAC + 动态光斑背景 + KPI 卡 + cardIn 入场 + 渐变按钮)。在线/成功状态仍用绿色 #17a964(与 ModelRouter 一致,不破坏 webui4frpc "绿=在线" 语义)。不加强调色切换器/深色模式(控范围,可后续加)。
- **模拟 frps = 加 2 个真实 frps 容器**frps2/frps3+ 一个死地址 remote`test-forward.sh` 验证 下发→claim→起 worker→连通 全链路。
## P0关键 Bug集群已炸
---
### A1. Leader 退出后无 failover — ring_engine.go `runCommands` 自退出分支 (L358-383)
**根因**leader 自退出时 `SelfRemove``LeaderID=""` → token 携空 LeaderID → 后继 `IsLeader()=false` → 调 `Forward``Send` → cycle 永不推进;`WatchLeader``AlivePredecessor("")` 失败 → 无人晋升 → ring 无 leader 直至 token 丢失。
## Part 1 — 主题令牌theme.css + variables.scss换成 sakura/frost
**修复**:在 SelfRemove 前捕获 `wasLeader`,之后若 wasLeader 则指定 `removedNext`(原后继)为新 leader
```go
wasLeader := e.state.LeaderID == e.ID
if succ, ok := e.state.AliveSuccessor(e.ID); ok && succ != e.ID {
e.removedNext = succ // 已有逻辑L372-374
}
e.state.SelfRemove(e.ID) // 已有
e.selfRemoved = true // 已有
// 新增leader 退出时指定后继为新 leader
if wasLeader && e.removedNext != "" {
e.state.LeaderID = e.removedNext
for i := range e.state.Nodes {
e.state.Nodes[i].IsLeader = e.state.Nodes[i].ID == e.removedNext
}
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.removedNext})
}
}
```
后继收到 token 时 `IsLeader()=true` → 调 `Send`bump cycle→ ring 恢复正常。
**web/src/styles/theme.css**(重写 `:root` + 增全局组件类):
- 调色:`--w4f-primary:#FF7FAC; -h:#F33B7C; -50:#FFF0F5; -100:#FFE4E9; -200:#FFCDD9; -300:#FF9EB5``--w4f-secondary:#88C0D0; -h:#4C8DAE; -50:#F0F9FC``--w4f-danger:#DB3694; -50:#FEEAF6``--w4f-ok:#17a964; --w4f-warn:#b7791f; --w4f-info:#3f6ef5`
- 表面/文字:`--w4f-bg-1:#eef2ff; -2:#fff; -3:#ffe9f0``--w4f-card:rgba(255,255,255,.60); -2:rgba(255,255,255,.42)``--w4f-line:rgba(255,127,172,.18); -strong:rgba(120,90,150,.22)``--w4f-fg:#3b3350; --w4f-muted:#7c7a95`
- 光斑:`--w4f-blob1/2/3`sakura/frost/pink 透明色)。
- 阴影:`--w4f-sh-sm/md/lg`(紫灰调 `rgba(120,90,160,..)``--w4f-glass:18px; --w4f-ease-spring:cubic-bezier(.34,1.56,.64,1)`
- EP 覆盖:`--el-color-primary:#FF7FAC` + light-3/5/7/8/9 + dark-2`--el-color-success:#17a964; danger:#DB3694;` 圆角/字体。
- body14px/1.55 `Quicksand,Nunito,Inter,...` 字号栈(不联网加载 web 字体用系统栈接近letter-spacing .02em。
- **动态光斑背景**`#bgfx` fixed + 3 `.blob` `filter:blur(64px)` + `@keyframes drift1/2/3``prefers-reduced-motion` 关动画。
- 全局类:`.w4f-card`(玻璃 + `cardIn` 入场动画)、`.w4f-kpi`KPI 卡基座 k-lab+blink dot/k-val/k-sub/k-chart 位)、`.w4f-bar`5px 渐变条)、`.w4f-tag` 变体、`.w4f-btn`(渐变主按钮 + ghost/danger/small 变体、sakura 滚动条。
### A2. 退出后残留旧集群信息 — ring_engine.go `Forward` (L439-461) + 新 `detachAsStandalone()`
**根因**`SelfRemove` 只移除自己出 Nodes保留 Topology/Pending/Log/其他成员。退出后不再收 token → `e.state` 冻结 → `Snapshot()` 返回旧画面。非前端 bug。
**web/src/styles/variables.scss**SCSS 变量重映射,保持视图内 `$color-*` 自动跟随)
- `$color-primary:#FF7FAC; -h:#F33B7C; -50:#FFF0F5; -100:#FFE4E9``$color-secondary:#88C0D0; -50:#F0F9FC``$color-success:#17a964`(保绿);`$color-danger:#DB3694; -50:#FEEAF6``$color-info:#3f6ef5`
- 文字色换紫灰调primary #3b3350 / secondary #5a5470 / muted #7c7a95 / light #9c98b0glass/shadow/radius/ease 对齐 theme.css。
**修复**
1. 新增 `detachAsStandalone()`(≈ CreateCluster 去掉 IsMember 守卫 + 重置 Log
```go
func (e *Engine) detachAsStandalone() {
e.state = State{
LeaderID: e.ID, PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{}, RoundDelay: 2 * time.Second,
}
e.state.UpsertNode(Node{ID: e.ID, Addr: e.myAddr, Alive: true, IsLeader: true,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache})
e.selfRemoved = false; e.removedNext = ""
e.lastRingStart = time.Time{}; e.lastTokenAt = 0; e.lastSyncAt = 0
e.inflight.clear(); e.failCount = map[string]int{}; e.published = map[string]struct{}{}
if e.Log != nil {
e.Log = NewClusterLog() // 清空旧集群日志
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.ID})
}
log.Printf("ring[%s] detached to standalone after self-leave", e.ID)
}
```
2.`Forward` 中,将 token 交给 removedNext 后(已发送),若 `e.selfRemoved` 则 detach
```go
func (e *Engine) Forward(ctx context.Context, tk *Token) error {
if e.removedNext != "" {
next := e.removedNext; e.removedNext = ""
var err error
if e.send != nil {
err = e.send(ctx, next, tk) // 先发 token携带旧 state 含新 leader
}
if e.selfRemoved { // 发完后 detach不影响已发 token
e.detachAsStandalone()
}
return err
}
// ... 原有逻辑
}
```
**效果**:退出后 `Snapshot()` 返回 standalone1 节点=自己0 转发,空日志)→ 前端顶部数据区显示"待加入"+ 1 成员 + 0 活跃转发 → 不再残留旧拓扑/转发/日志。
## Part 2 — App.vue玻璃侧栏 + 光斑背景层 + 面包屑
### A3. 测试
- 现有 `ring_remove_test.go` 3 个测试全是非 leader 退出wasLeader=false 不指定后继detach 在 Forward 中测试不调)→ 不受影响 ✓
- 新增 `TestLeaderSelfRemoveDesignatesSuccessor`leader 自退出 → 后继被指定为新 leader
- 新增 `TestDetachAfterForward`:自退出 + Forward 后 state 重置为 standalone
- 模板加 `<div class="bgfx" aria-hidden="true"><i class="blob b1"/><i class="blob b2"/><i class="blob b3"/></div>` 作固定背景z-index -10
- 侧栏 246px 玻璃(`backdrop-filter:blur(glass*.7) saturate(1.3)`),品牌区渐变 logo 方块 "W"(渐变 primary→secondary + 阴影nav item active = ModelRouter 渐变瓦片(`linear-gradient(120deg,primary-50,secondary-50)` + primary-h 文字 + sh-sm而非当前整块填色footer 活跃点 + "token-ring · M6" 药丸。
- 内容区顶部加 `.breadcrumb` 显示当前视图名(状态/连接配置/设置/集群),对齐 ModelRouter 布局。
- `.content` 滚动区,各视图自带内边距。
## P1State.Seq 删除 — ring.go + ring_engine.go
plan.md 从未要求 SeqNextTaskID 注释 L213-221 已自述回归 bug
- ring.go L90State 结构体删 `Seq int64 json:"seq"`
- ring.go L212-235 NextTaskID`max := int64(0)`(删 `max := s.Seq`),删 `s.Seq = max+1``return fmt.Sprintf("t%d", max+1)`
- ring_engine.go L635CreateCluster 删 `Seq: e.state.Seq`
- LogEntry.Seqring_log.go保留日志水印plan §增量日志同步)
## Part 3 — StatusView.vue 重设计
## P1集群加入密钥per-node 准入密钥)
- 顶栏玻璃卡:面包屑标题 + "每 5 秒自动刷新" + 刷新(ghost) + 添加远程节点(渐变 primary)。
- **KPI 网格**auto-fit minmax 168px远程节点数sub: 在线 X、在线 worker 数、本地服务数、活跃转发数。每卡 k-lab+blink dot、k-val 27px tabular-nums、k-sub。
- 远程节点卡:玻璃 + cardIn左边框色按健康绿/红),名称 + 连接药丸ok 绿/pending 青/err 红点),操作按钮(启=渐变 / 停=ghost / 编辑=ghost / 删=danger smallmeta(pid/err/vhost 药丸)forwards 药丸 chipsper-proxy 行带 5px bar 指示。
- 本地服务卡玻璃proto 药丸targets 带 → + 状态药丸。
- el-dialog 沿用EP 覆盖自动 sakura 化)。
### 设计
- 每个节点有 `nodeKey`(随机 16 字节 hex首次启动生成、持久化到 DB、重启后读回稳定
- 画布 webui 显示本节点 nodeKey可复制加入集群对话框需填对端地址 + 对端 nodeKey
- 对端 `handleClusterJoin` 验证 `ji.JoinKey == e.nodeKey`,不匹配 403
- `CreateCluster`/`AdoptState` 不重新生成init 时已存在)
## Part 4 — ClusterView.vue 重设计
### B1. 数据模型
- `store.Settings`store.go L114-119`NodeKey string json:"nodeKey,omitempty"` + migrate 列 `node_key TEXT NOT NULL DEFAULT ''`
- `store.SetNodeKey(key string) error``UPDATE settings SET node_key=?`
- `Engine` structring_engine.go L24-80`nodeKey string` + `func (e *Engine) NodeKey() string { return e.nodeKey }`
- `JoinInfo`ring_engine.go L544-550`JoinKey string json:"joinKey,omitempty"`
- `RingSnapshot`ring_engine.go L464-):加 `NodeKey string json:"nodeKey"`
- Hero 玻璃卡:渐变标题 + sub状态药丸运行中=绿点 blink / 待加入=青 / 已脱离=红)+ 成员数 + 周期 + leader ♔。
- **KPI 行**:成员数、活跃转发、待办命令、周期。
- 操作工具条:创建集群(渐变 primary) / 加入…(ghost) / 退出(danger) / 刷新(ghost small),保留既 `!isMember`/`isMember` 安全门。
- 环拓扑横向玻璃节点卡链leader=青调 + 边框、self=sakura 高亮发光、dead=红暗;负载 baraddr mono移除按钮(small danger);→ 箭头。
- 待办命令/活跃拓扑/集群日志:玻璃卡 + sticky 表头 + 药丸标签(新增 sakura / 撤销·移除 danger / owner self=sakura+ 彩色 kind 徽章 + self 高亮,全部 cardIn 入场。
- 保留加入 el-dialog。
### B2. 初始化 + 持久化
- `NewEngine`ring_engine.go L84-105签名加 `nodeKey string` 参数
- main.go 引擎初始化L121`settings.NodeKey`;若空 → `crypto/rand` 生成 16 字节 hex → `store.SetNodeKey` 持久化 → 传给 NewEngine
## Part 5 — 模拟 frps 节点 + 转发下发测试
### B3. 加入验证
- `handleClusterJoin`handlers.go L530-556decode JoinInfo 后加 `if ji.JoinKey != h.Ring.NodeKey() { 403 }`
- `JoinRingAddr`token_join.go L54-57签名加 `joinKey string`,设 `ji.JoinKey = joinKey`
- `JoinRing`token_join.go L16-47JoinInfo 已含 JoinKey随 POST body 发送
**新增 frps 配置 + 容器**
- `deploy/frps2.toml`bindPort 7001, vhostHTTPPort 7081, auth.token=demo-token。
- `deploy/frps3.toml`bindPort 7002, vhostHTTPPort 7082, auth.token=demo-token。
- `deploy/run-cluster.sh` 增启动 `w4f-frps2`(--network-alias frps2, -p 7001:7001 -p 7081:7081) 与 `w4f-frps3`(alias frps3, -p 7002:7002 -p 7082:7082)。
- `deploy/compose.yaml` 增 frps2/frps3 服务compose 用户同步)。
- `deploy/start-mock-frps.sh`:仅启 frps2/frps3`docker rm -f` 这两个再 run供集群已起时补启。
### B4. -peer 启动引导
- main.go flags`-join-key string`(或 `W4F_JOIN_KEY` env
- main.go L336-346 bootstrap`ring.JoinRing(jc, peers[0], ji)``JoinRingAddr(ctx, peers[0], *joinKey)`
**`deploy/test-forward.sh`**核心验证脚本Basic Auth admin:admin123
1. 确认 ring`GET node-a:7501 /cluster/ring` 断言 3 节点。
2. 快照原 canvas`GET node-a /canvas` 存原始 `{locals,remotes,links}`
3. 创建 4 条转发(`PUT node-a /canvas`localOnly=falselocal.ip=`backend` docker DNSport 8080
- `fwd-frps1` → remote frps:7000, remotePort 18091
- `fwd-frps2` → remote frps2:7001, remotePort 18092
- `fwd-frps3` → remote frps3:7002, remotePort 18093
- `fwd-dead` → remote 192.0.2.1:7000, remotePort 18094死地址验下发+起 workerconn 失败)
4. 轮询 `GET /cluster/ring`1s×~25断言 4 条 pending→0 且 topology 出现 4 条带 ownerId打印每条由谁 claim最低负载
5. 轮询各 owner `GET /status`:断言对应 remote process.state=running。
6. 轮询 owner `GET /profiles/{remote}/logs`:真实 frps 断言含 `login to server success`dead 断言 worker running 但 connState=failed。
7. 撤销测试:`PUT /canvas` 还原为步骤2快照移除我们的 locals→ 轮询 topology 4 条消失 + owner /status worker 已停。
8. 打印结果表task / target / claimed-by / worker / conn / 结论)。
9. 清理canvas 已在步骤7还原不删容器。
### B5. 前端
- types.ts`RingSnapshot``nodeKey?: string`
- api.ts `clusterJoinRing`L137-142`(addr, joinKey)` → body `{addr, joinKey}`
- ClusterView.vue
- 顶部 hero 或独立区域:显示本机 nodeKey`ring.nodeKey`+ 复制按钮
- 加入对话框L167-178加"加入密钥"输入框(`joinDialog.key`
- `onJoin`L369-383`api.clusterJoinRing(addr, key)`
- `joinDialog` reactiveL193`key: ''`
**验证连通可选项**:真实 frps 的 vhost/remotePort 不映射到宿主dispatch 验证不需打隧道);如需端到端打隧道验证,可 `docker exec` 在 net 内 curl `frps2:18092` 命中 backend——纳入脚本 step 6.5。
## P2画布边启用/禁用开关 + 分组标签 — PortEdge.vue + CanvasView.vue
## Part 6 — 构建 + 验证
### PortEdge.vue
- computed `isDisabled`(读 `props.data?.disabled`)、`groupName`(读 `props.data?.group`
- emits 加 `toggle-disabled``edit-group`payload `{edgeId}`
- 模板 `.port-label` 内端口号旁:`<span class="port-toggle" :class="{off:isDisabled}" @pointerdown.stop @click.stop="emit('toggle-disabled',{edgeId:props.id})">{{ isDisabled?'禁':'通' }}</span>``<span v-if="groupName" class="port-group" @pointerdown.stop @click.stop="emit('edit-group',{edgeId:props.id})">{{ groupName }}</span>`
- `@pointerdown.stop` 阻止拖拽label 的 onPointerDown 不触发 → dragging 保持 false → onPointerUp `!wasDrag` 直接 return不误开端口编辑器
- strokeColordisabled 时灰色(`var(--el-color-info-light-5)`
1. `cd web && npm run build`
2. 同步前端:`rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist`
3. `go build -o deploy/artifacts/webui4frpc ./cmd/webui4frpc`
4. 起/刷集群:`bash deploy/run-cluster.sh`(或既有集群:`bash deploy/start-mock-frps.sh && docker restart w4f-node-a w4f-node-b w4f-node-c`
5. `bash deploy/test-forward.sh` —— 验证转发下发链路
6. **视觉验证**(直击"太丑"反馈):用浏览器打开 `http://localhost:7501`admin/admin123截图 状态页 + 集群页,对照 ModelRouter 确认 光斑背景/玻璃 KPI 卡/渐变按钮/入场动画 到位。若不符则迭代。
### CanvasView.vue
- L60-67 edge 模板:加 `@toggle-disabled="onToggleDisabled" @edit-group="onEditGroup"`
- import ElMessageBox
- `onToggleDisabled({edgeId})`:翻 `edge.data.disabled``dirty=true`
- `onEditGroup({edgeId})`ElMessageBox.prompt列现有分组名留空=未分组)→ 设 `edge.data.group``dirty=true`
- buildPayload L638 已序列化 disabledL637 已序列化 group保存重建 L690-691 已回填 → 无需改)
## P2状态页分组管理 — store + handler + route + api + StatusView
### 后端
- store.go`SetLinkGroup(local, remote string, port int, group string) error``UPDATE links SET grp=? WHERE ...`;列 grp 已存在)
- handlers_forwards.go`assignReq{Local,Remote,RemotePort,Group}` + `handleForwardsAssign`findLinkByTriple→SetLinkGroup+ `handleForwardsGroupDelete`ListLinks 过滤 group→逐条 SetLinkGroup(...,"")
- server.go L78-81 路由块:`/forwards/assign``/forwards/group/delete`(均 write 级)
### 前端
- api.ts`assignGroup(local,remote,remotePort,group)` + `deleteGroup(group)`
- StatusView.vue
- 卡片 `.fwd-head` L70 后:`<span class="w4f-tag w4f-tag-info group-chip" @click="editGroup(f)">{{ f.group || '未分组' }}</span>`
- 分组头 L45-48 `.grp-actions``<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>`
- `editGroup(f)`ElMessageBox.prompt列现有分组留空=移出)→ api.assignGroup→loadStatus
- `deleteGroup(group)`confirm→api.deleteGroup→loadStatus
- import ElMessageBox
## 不变(保留)
- 状态页所有现有按钮(启动/停止转发、一键启动/停止整组)+ forwards 端点
- handlers_forwards.go 全部现有函数
- ring 语义SubmitTask/RevokeTask/HasTask/leader 选举/拓扑)
- 画布端口编辑对话框已有"分组"字段(保留,边上标签是额外快速入口)
## 构建验证
1. `go test ./internal/cluster/...`Seq 删除 + leader failover + detach 不破坏现有测试 + 新测试通过)
2. `cd web && npm run build``cp -r web/dist internal/httpapi/dist``go build -o deploy/artifacts/webui4frpc ./cmd/webui4frpc`
3. `docker rm -f w4f-node-a b c d e; bash deploy/run-cluster.sh`
4. **leader failover**a 为 leader → a 退出 → b 成为新 leadercycle 继续推进)→ ring 正常
5. **退出残留**a 退出后 a 的集群页显示 standalone1 节点、0 转发、空日志),不残留旧拓扑
6. **加入密钥**:创建集群 → 显示 nodeKey → b 加入需填 a 的 nodeKey → 错误密钥 403 → 正确密钥加入成功
7. **画布边开关**:点"禁"→边变灰→保存→状态页 stopped点"通"→恢复
8. **画布边分组**:点分组标签→输入名→保存→状态页显示分组
9. **状态页分组管理**:卡片分组标签→改组→即时生效;分组头删除分组→全移至未分组
10. `bash deploy/test-forward.sh` 通过
## 影响文件
- 主题`web/src/styles/theme.css`(重写)、`web/src/styles/variables.scss`(重映射)
-`web/src/App.vue`(侧栏+光斑+面包屑)
- 视图:`web/src/views/StatusView.vue``web/src/views/ClusterView.vue`(重设计)
- 部署:`deploy/frps2.toml``deploy/frps3.toml`(新)、`deploy/run-cluster.sh``deploy/compose.yaml``deploy/start-mock-frps.sh`(新)、`deploy/test-forward.sh`(新)
- Go`internal/cluster/ring.go``ring_engine.go``ring_leader.go`(仅注释/无改)、`token_join.go``internal/store/store.go``internal/httpapi/handlers.go``handlers_forwards.go``server.go``cmd/webui4frpc/main.go`
- 前端`web/src/components/PortEdge.vue``views/CanvasView.vue``views/ClusterView.vue``views/StatusView.vue``api.ts``types.ts`

170
README.md
View File

@ -1,17 +1,38 @@
# webui4frpc
一个**独立于 frp 源码**的可视化 frpc 控制器。用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,免去运维反复手写 frpc 配置的麻烦。
一个**独立于 frp 源码**的可视化 frpc 控制器。用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,免去运维反复手写 frpc 配置的麻烦。支持多节点组成令牌环集群,协同分发转发任务。
> 本仓库**不捆绑、不包含任何 frp 源码**。frpc 可执行文件由用户一键从官方 GitHub Releases 下载,或手动指定路径。
## 特性
### 画布配置
- **Scratch 风格画布**本地转发项local与远程服务器remote可视化连线一个 local 可连多个 remote一个 remote 可连多个 local
- **曲线连线 + 端口标签**:每条连线独立曲线,标签为远程端口,可拖拽调整曲线位置,端口可点开编辑
- **边开关与分组**:画布边上可一键禁用/启用单条转发,可打分组标签(状态页按分组管理、一键启停整组)
- **一键生成配置**:保存画布即渲染每台 remote 的 frpc JSON 配置,自动拉起/重启对应 worker
- **画布导入导出**:支持 JSON 备份/恢复
### frpc 能力
- **代理类型**tcp完整、http/httpscustomDomains / subdomain / locations / basicAuth / headerRewrite
- **传输参数**useEncryption / useCompression / bandwidthLimit / poolCount / transport.protocoltcp/quic/kcp/websocket/ TLS
- **负载均衡与健康检查**lbGroup + healthCheck 渲染输出;状态页通过 frpc admin API 拉取 per-proxy 真实状态running / check failed / wait start …)
- **worker 自愈**:崩溃自动重启(指数退避),随 webui 启停
- **frpc 一键安装**:从官方 Releases 下载任意版本 frpc或手动指定二进制路径
- **token 可视化**remote 支持 token/URL 等字段
### 集群模式(令牌环)
- **令牌环协议**:若干 webui4frpc 节点组成协作网络;令牌按固定顺序传递,每节点持完整集群信息,单轮即全网收敛
- **负载最低者摘取**:新转发任务附加在令牌中,由负载最低的节点自行摘取并创建 frpc worker
- **容错自愈**leader / 非 leader 节点宕机 → 自动检测 → 环跳过死亡节点、任务重挂leader 宕机 → 上邻居晋升为新 leader
- **宕机自动重联**:节点宕机重启后通过缓存的集群拓扑(对端地址 + 准入密钥)自动重联,显式脱离集群的节点不自动重联
- **准入密钥**:每节点有 nodeKey新节点加入需提供对端密钥密钥随令牌环交换全节点互通
- **集群二进制分发**:节点间优先互传 frpc 二进制,外部 URL 兜底
### 认证与权限
- **Basic Auth**启动参数配置管理员账号密码flag-creds 快速路径,不走 bcrypt
- **用户管理**admin / viewer 两种角色bcrypt 存储webui 管理
- **API Key**read / write / admin 三级 scope`w4f_` 前缀sha256 存储,创建时明文仅展示一次
- **三级权限**read查看< write修改< admin用户/密钥管理
## 构建
@ -27,12 +48,18 @@ go build -o webui4frpc ./cmd/webui4frpc
cd web && npm install && npm run build
```
前端产物会进入 `web/dist`,需要拷贝到 `internal/httpapi/dist` 以便 Go `embed` 打包进单一二进制
前端产物 `web/dist`拷贝到 `internal/httpapi/dist` 以便 Go `embed` 打包进单一二进制
```bash
rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
```
一键全量构建
```bash
cd web && npm run build && cd .. && cp -r web/dist/* internal/httpapi/dist/ && go build -o deploy/artifacts/webui4frpc ./cmd/webui4frpc
```
## 运行
```bash
@ -41,53 +68,142 @@ rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
打开 http://127.0.0.1:7500/ 输入账号密码进入画布首次使用可到设置页一键安装 frpc或手动指定路径然后回到画布创建 local / remote 并连线保存
参数
### 参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `-addr` | `127.0.0.1:7500` | 监听地址 |
| `-user` / `-password` | `admin`/`admin` | Basic 认证 |
| `-workdir` | `./webui4frpc` | 数据目录db/配置/日志/bin |
| `-bin` | 空 | 初始 frpc 路径(可选) |
| `-user` / `-password` | `admin` / `admin` | Basic 认证自动同步为 system 管理员账号 |
| `-workdir` | `./webui-frpc` | 数据目录db / 配置 / 日志 / bin |
| `-bin` | | 初始 frpc 二进制路径可选首次启动写入 settings |
| `-frpc` | | worker 默认 frpc 路径settings 无值时的回退 |
| `-peer` | | 集群对端地址 `host:port`可重复仅首次引导加入用 |
| `-join-key` | `$W4F_JOIN_KEY` | 集群准入密钥 = 对端节点的 nodeKey `-peer` 配合使用 |
环境变量 `W4F_HOST` 可覆盖节点对外可达的主机名Docker DNS / 通配监听场景用)。
### 启动行为
节点启动时的集群引导优先级
1. **缓存拓扑优先**读取持久化的 `ClusterPeers`宕机前最后令牌周期保存的对端列表逐个尝试 rejoin
2. **`-peer` 引导**无缓存拓扑时 `-peer` + `-join-key` 加入指定对端10s 重试5min 超时
3. **新建集群**无缓存无 `-peer` `CreateCluster` 成为独立 leader
显式脱离集群detachAsStandalone会清除 `ClusterPeers` 重启后不自动重联
## 数据目录
```
<workdir>
├── manager.db # SQLite 状态locals/remotes/links/settings
├── configs/<name>.json # 每台 remote 渲染的 frpc 配置
├── logs/<name>.log # worker 日志(按大小轮转)
└── bin/frpc-<v> # 一键安装的 frpc
├── manager.db # SQLitelocals / remotes / links / settings / users / api_keys
├── configs/<name>.json # 每台 remote 渲染的 frpc 配置
├── logs/<name>.log # worker 日志(按大小轮转)
└── bin/frpc-<v>/frpc # 一键安装的 frpc
```
## 架构
```
webui4frpc单二进制无 frp 依赖)
├── cmds/webui4frpc 入口:HTTP 服务 + 生命周期
├── internal/store SQLite 持久化
├── internal/canvas 画布模型local/remote/link
├── internal/render 渲染 frpc JSON 配置
├── internal/process worker 进程管理spawn/stop/restart/自愈
├── internal/httpapi REST API + 静态资源
└── internal/install 一键下载官方 frpc
webui4frpc单二进制无 frp 依赖,前端 go:embed
├── cmd/webui4frpc 入口flag 解析 → store/process/cluster 装配 → HTTP 服务 + 生命周期
├── internal/
│ ├── store SQLite 持久化locals / remotes / links / settings / users / api_keys
│ ├── canvas 画布模型local / remote / link 多对多)
│ ├── render 渲染 frpc JSON 配置tcp/udp/http/https + 高级参数 + LB/健康检查
│ ├── process worker 进程管理spawn/stop/restart/自愈/日志轮转)
│ ├── install 一键下载官方 frpcGitHub Releases含解压安全防护
│ ├── cluster 令牌环协议ring engine / leader 选举 / 容错 / 宕机重联 / 二进制分发)
│ └── httpapi REST API + 静态资源auth / canvas / forwards / cluster / users
└── web/ Vue 3 SPAElement Plus + VueFlow 画布 + Pinia
└── src/views/ 状态总览 / 连接配置(画布)/ 集群 / 设置 / 账号与密钥
```
## API
| 方法 | 路径 | 说明 |
所有接口前缀 `/api/manager`使用 Basic Auth Bearer API Key 认证权限分 read / write / admin 三级
| 类别 | 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|---|
| **状态** | GET | `/status` | read | 总览版本/服务/节点/转发/profiles |
| **画布** | GET/PUT | `/canvas` | read/write | 读写完整画布 |
| | GET | `/canvas/export` | read | 导出画布备份 |
| | POST | `/canvas/import` | write | 导入画布 |
| **转发** | POST | `/forwards/start\|stop` | write | 启停单条转发 |
| | POST | `/forwards/group/start\|stop` | write | 启停整组 |
| | POST | `/forwards/assign` | write | 修改转发分组 |
| | POST | `/forwards/group/delete` | write | 删除分组 |
| **单资源** | PUT/DELETE | `/locals[/{name}]` | write | 增删 local |
| | PUT/DELETE | `/remotes[/{name}]` | write | 增删 remote |
| | POST/DELETE | `/links[/{id}]` | write | 增删 link |
| **Profile** | POST | `/profiles/{name}/start\|stop\|restart` | write | worker 启停重启 |
| | GET | `/profiles/{name}/config\|logs` | read | 配置/日志 |
| **设置** | GET/PUT | `/settings` | read/write | 运行策略 |
| | GET | `/binary/status` | read | frpc 路径 |
| | POST | `/binary/install` | write | 安装 frpc |
| **集群** | GET | `/cluster/ring` | read | 令牌环快照 |
| | POST | `/cluster/create` | write | 创建独立集群 |
| | POST | `/cluster/join-ring` | write | 加入集群本节点发起 |
| | POST | `/cluster/join` | write | 接收新节点加入 |
| | POST | `/cluster/task` | write | 提交转发任务到环 |
| | POST | `/cluster/node-remove` | write | 移除节点 |
| | POST | `/cluster/token` | write | 令牌中继 + 心跳 |
| | GET | `/cluster/nodes` | read | 集群节点 + 缓存 |
| | GET/POST | `/cluster/cache` | read/write | 二进制缓存管理 |
| | GET | `/node/logs` | read | 本节点 worker 日志 |
| | GET | `/cluster/logs/export` | read | 导出全节点 worker 日志 |
| | GET | `/frpc/{version}` | read | 节点间 frpc 二进制分发 |
| **账号** | GET | `/me` | read | 当前身份 |
| | GET/POST | `/users` | admin | 用户列表/创建 |
| | PUT/DELETE | `/users/{name}` | admin | 修改/删除用户 |
| | GET/POST | `/apikeys` | admin | API Key 列表/创建 |
| | DELETE | `/apikeys/{id}` | admin | 吊销 API Key |
> 集群管理 API 的详细说明(请求体、响应格式、错误码、示例)见 [docs/cluster-api.md](docs/cluster-api.md)。
## 集群模式
多个 webui4frpc 节点可组成令牌环集群协同工作
- **创建集群**首个节点 `CreateCluster` 成为 leader生成准入密钥nodeKey
- **加入集群**在集群页复制对端的 nodeKey填入加入集群对话框对端地址 + 密钥
- **任务分发**在任意节点画布上配置 `localOnly=false` 的转发并保存 画布差异判断生成命令 令牌携带 负载最低节点摘取 拉起 frpc worker
- **容错**节点宕机 自动检测 环自愈跳过死亡节点 / leader failover重启 自动重联
- **退出集群**在集群页退出集群」→ 本节点脱离为 standalone不自动重联
集群页展示环拓扑self/leader 标记)、令牌轮次节点负载待办任务活跃转发拓扑 owner 归属)、增量事件日志
## 集群演示
```bash
# 构建
go build -o deploy/artifacts/webui4frpc ./cmd/webui4frpc
# 拉起 4 节点集群 + 1 孤立节点Docker
cd deploy && bash run-cluster.sh
# 运行转发测试(提交 4 条转发 → 验证分发 → 撤销)
bash test-forward.sh
```
节点端口映射
| 节点 | 端口 | 角色 |
|---|---|---|
| GET | `/api/manager/status` | 总览状态 |
| GET/PUT | `/api/manager/canvas` | 读写画布 |
| GET/PUT | `/api/manager/settings` | 读写运行策略 |
| POST | `/api/manager/binary/install` | 一键安装 frpc |
| POST | `/api/manager/profiles/{name}/start\|stop\|restart` | worker 启停 |
| GET | `/api/manager/profiles/{name}/config\|logs` | 查看配置/日志 |
| node-a | 7501 | 集群 leaderseed frpc 缓存 |
| node-b | 7502 | 集群成员 |
| node-c | 7503 | 集群成员 |
| node-d | 7504 | 集群成员 |
| node-e | 7505 | 孤立节点standalone未加入集群 |
所有节点 `-user=admin -password=admin123`
## 测试
```bash
# 后端
go test ./...
# 前端
# 前端类型检查
cd web && npm run type-check
```

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"

363
docs/cluster-api.md Normal file
View File

@ -0,0 +1,363 @@
# webui4frpc 集群管理 API
所有接口前缀 `/api/manager`,使用 Basic Auth 认证(`-user` / `-password` 启动参数)。
权限分级:
- **read**viewer + read-scope API key + admin 均可访问
- **write**write-scope API key + admin启动参数的 Basic Auth 凭证解析为 admin
---
## 1. 查看集群状态
### `GET /cluster/ring`
返回当前节点的令牌环快照(领导者、轮次、节点表、待办任务、拓扑、日志、本机 nodeKey
**权限**read
**响应**
```json
{
"selfId": "node-a:7500",
"leaderId": "node-a:7500",
"cycle": 152,
"lastSync": 1787141340,
"roundDelayMs": 2000,
"nodeKey": "ed67ca6b181b3cf2...",
"nodes": [
{
"id": "node-a:7500",
"addr": "node-a:7500",
"isLeader": true,
"alive": true,
"load": { "memPct": 17.5, "netPct": 10, "forwards": 0 },
"version": "0.1.0",
"nodeKey": "ed67ca6b181b3cf2...",
"lastSeen": 1787141340
}
],
"pending": [],
"topology": [
{
"id": "t1",
"local": { "name": "web", "port": 8080 },
"remote": { "name": "frps", "addr": "frps:7000" },
"link": { "remotePort": 8080 },
"owner": "node-b:7500",
"active": true
}
],
"log": [
{ "seq": 1, "node": "node-a:7500", "kind": "leader.change", "detail": {"leader":"node-a:7500"}, "ts": 1787141300 }
]
}
```
**字段说明**
| 字段 | 说明 |
|---|---|
| `selfId` | 本节点 ID`host:port` |
| `leaderId` | 当前 leader 节点 ID |
| `cycle` | 令牌环当前轮次(每完成一圈 +1 |
| `roundDelayMs` | 轮次延迟毫秒leader 每轮探测后更新 |
| `nodeKey` | 本节点的准入密钥(新节点加入本节点时需提供此密钥) |
| `nodes[]` | 环中所有节点(含离线的),按插入顺序排列(即环顺序) |
| `nodes[].nodeKey` | 该节点的准入密钥(随令牌环交换,用于宕机重联) |
| `pending[]` | 待摘取的转发任务 |
| `topology[]` | 活跃转发拓扑(含 owner 归属) |
| `log[]` | 集群事件日志(增量同步后的本地视图) |
---
## 2. 创建集群
### `POST /cluster/create`
将本节点重置为全新的独立 leader单节点环。生成准入密钥nodeKey其他节点可通过此密钥加入。
**权限**write
**前置条件**:本节点不能是多人环成员(已是成员返回 409
**请求体**:无
**响应**:同 `GET /cluster/ring` 的快照
**错误**
| 状态码 | 说明 |
|---|---|
| 409 | 已是多人环成员,需先脱离集群 |
**示例**
```bash
curl -X POST -u admin:admin123 http://localhost:7501/api/manager/cluster/create
```
---
## 3. 加入集群(本节点发起)
### `POST /cluster/join-ring`
本节点作为新节点,向目标对端发起加入请求。对端验证密钥后将本节点插入环,返回完整环状态供本节点采纳。
**权限**write
**前置条件**:本节点不能已是多人环成员(已是成员返回 409
**请求体**
```json
{
"addr": "192.168.1.10:7500",
"joinKey": "ed67ca6b181b3cf2..."
}
```
| 字段 | 说明 |
|---|---|
| `addr` | 对端节点的真实可路由地址IP:port 或域名:port |
| `joinKey` | 对端节点的 nodeKey从对端的集群页或 `GET /cluster/ring` 获取) |
**响应**:同 `GET /cluster/ring` 的快照
**错误**
| 状态码 | 说明 |
|---|---|
| 400 | `addr``joinKey` 为空 |
| 409 | 已是多人环成员 |
| 502 | 对端不可达或加入失败(密钥错误返回 403、网络超时等 |
**示例**
```bash
curl -X POST -u admin:admin123 http://localhost:7502/api/manager/cluster/join-ring \
-H 'Content-Type: application/json' \
-d '{"addr":"192.168.1.10:7500","joinKey":"ed67ca6b181b3cf2..."}'
```
---
## 4. 加入集群(对端接收)
### `POST /cluster/join`
**节点间内部接口**:新节点通过 `POST /cluster/join-ring` 间接调用此接口。也可直接调用(例如用 curl 模拟新节点加入)。
接收新节点的加入请求,验证密钥后将新节点排在自己后面(成为自己的后继),返回环状态。
**权限**write
**请求体**`JoinInfo`
```json
{
"id": "node-b:7500",
"addr": "node-b:7500",
"version": "0.1.0",
"cache": ["0.54.0"],
"joinKey": "ed67ca6b181b3cf2..."
}
```
**响应**
```json
{
"state": { /* 完整 State环状态 */ }
}
```
**错误**
| 状态码 | 说明 |
|---|---|
| 400 | JSON 解析失败 |
| 403 | `joinKey` 为空或不匹配本节点的 nodeKey |
---
## 5. 提交转发任务
### `POST /cluster/task`
将一个转发任务附加到令牌,由负载最低的节点摘取并创建 frpc worker。
**权限**write
**请求体**
```json
{
"local": { "name": "web", "port": 8080, "type": "tcp" },
"remote": { "name": "frps", "addr": "frps:7000", "enabled": true },
"link": { "remotePort": 8080, "localPort": 8080 }
}
```
**响应**
```json
{
"task": {
"id": "t1",
"local": { "name": "web", "port": 8080, "type": "tcp" },
"remote": { "name": "frps", "addr": "frps:7000" },
"link": { "remotePort": 8080 },
"created": 1787141340
}
}
```
任务随令牌环行,负载最低的节点摘取后:
1. 持久化 local/remote/link 到本地 store
2. 拉起 frpc worker
3. 写入拓扑(`topology[].owner = 摘取节点`
4. 下一轮令牌全网收敛一致
---
## 6. 移除节点
### `POST /cluster/node-remove`
发布 `node.remove` 命令到令牌。令牌传递到被移除节点自身时,该节点执行自移除:
1. 将自己负责的转发任务重新追加为待办
2. 修改拓扑与转发链、移除自身
3. 令牌传递给自身原本的下一家
4. 脱离后清空本地集群视图(仅保留 localOnly 转发)
**权限**write
**请求体**
```json
{ "id": "node-c:7500" }
```
**响应**
```json
{
"task": {
"id": "t2",
"type": "node.remove",
"created": 1787141345
}
}
```
被移除节点脱离集群后:
- `nodeKey` 清空(不再有效)
- `ClusterPeers` 清空(重启不自动重联)
- 本地画布仅保留 localOnly 转发
---
## 7. 令牌中继 + 心跳
### `POST /cluster/token`
**节点间内部接口**:环上节点之间传递令牌 + leader 上邻居心跳探测。
**权限**write
**两种模式**
#### 令牌传递(有 body
请求体为 `cluster.Token` JSON。本节点
1. `OnToken`:采纳令牌中的集群状态 → 运行命令 → 追加自身信息
2. leader → `Send`bump cycle + 转发);非 leader → `Forward`(转发)
3. 异步转发,立即返回 200 + 更新后的 token
**响应**:更新后的 `Token` JSON
#### 心跳探测(空 body
leader 的上邻居每秒探测 leader 存活。空 body → 心跳。
- leader 在线且为多人环 → `200 OK`
- leader 已重启/脱离standalone ≤1 节点)→ `409 Conflict`(上邻居感知 leader 已退出 → 晋升自身为新 leader + StartRing
**错误**
| 状态码 | 说明 |
|---|---|
| 409 | standalone 节点拒绝心跳leader 已退出,触发 failover |
---
## 8. 集群节点 + 二进制缓存
### `GET /cluster/nodes`
列出本节点及已注册的集群对端,含各自缓存的 frpc 版本。
**权限**read
**响应**
```json
{
"nodes": [
{ "addr": "node-a:7500", "version": "0.54.0", "cache": ["0.54.0", "0.53.0"] },
{ "addr": "node-b:7500", "version": "0.54.0", "cache": [] }
]
}
```
### `GET /cluster/cache`
查看本节点缓存的 frpc 版本详情。
**权限**read
### `POST /cluster/cache`
裁剪缓存,保留最新 N 个版本。
**权限**writeGET 为 readPOST 需 write
**请求体**
```json
{ "keep": 3 }
```
---
## 9. 日志
### `GET /node/logs`
返回本节点所有 frpc worker 的日志尾部。
**权限**read
**响应**
```json
{
"node": "node-a:7500",
"workers": [
{ "name": "frps", "remote": "frps", "state": "running", "lines": "..." }
]
}
```
### `GET /cluster/logs/export`
并行拉取所有 alive 节点的 worker 日志,聚合为 JSON 附件下载。不可达节点标记 `error` 但不中断。
**权限**read
**响应**`Content-Disposition: attachment; filename="worker-logs.json"`
---
## 宕机重联行为
节点宕机重启时的自动重联逻辑(**非 API 接口,内部行为**
1. **读取缓存拓扑**:启动时从 `store.Settings.ClusterPeers` 读取上次令牌周期持久化的对端列表(`[{addr, key}, ...]`
2. **缓存优先于 `-peer`**:如有缓存拓扑,逐个尝试 rejoin用对端的 key 认证);无缓存才用 `-peer` + `-join-key` 首次引导
3. **重试**:每 10s 重试5min 超时后回退 CreateClusterstandalone
4. **显式脱离不重联**:通过 `POST /cluster/node-remove` 脱离集群时清除 `ClusterPeers` → 重启后不自动重联
---
## 容错行为
| 场景 | 检测机制 | 恢复动作 |
|---|---|---|
| 非leader节点宕机 | `forwardToNext` 发送失败 → MarkOffline → OfflineReassign | 环跳过死亡节点,任务重挂为待办 |
| leader 宕机(发送时) | `forwardToNext` 发送给 leader 失败 → MarkOffline | 上邻居晋升为新 leader + StartRing |
| leader 宕机(收到令牌后) | `WatchLeader` 心跳失败/409 → MarkOffline | 上邻居晋升 + StartRing兜底 |
| leader 快速重启 | 心跳得到 409standalone | 上邻居晋升 + StartRing |
| 令牌丢失 | `WatchTokenLoss` 超时LossTimeout | leader 重发令牌 |

7
go.mod
View File

@ -2,7 +2,10 @@ module webui4frpc
go 1.25.0
require modernc.org/sqlite v1.55.0
require (
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.55.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
@ -10,7 +13,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

6
go.sum
View File

@ -12,13 +12,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

View File

@ -30,15 +30,24 @@ type Node struct {
Version string `json:"version,omitempty"`
Cache []string `json:"cache,omitempty"`
LastSeen int64 `json:"lastSeen,omitempty"`
// NodeKey is this member's cluster admission key. It travels in the
// token so every node knows every peer's key — a crashed node can
// rejoin via ANY cached peer by presenting that peer's key. Without
// this, a rejoiner would only know its original sponsor's key (from
// the -join-key flag) and couldn't rejoin through a different peer.
NodeKey string `json:"nodeKey,omitempty"`
}
// Load is the combined load metric used to pick the task claimer.
type Load struct {
MemPct float64 `json:"memPct"`
NetPct float64 `json:"netPct"`
MemPct float64 `json:"memPct"`
NetPct float64 `json:"netPct"`
Forwards int `json:"forwards,omitempty"` // active forwards owned by this node (primary signal)
}
func (l Load) Score() float64 { return l.MemPct + l.NetPct }
// Score weights owned forwards heavily so the node with the fewest active
// forwards is picked first; mem/net only break ties at equal forward count.
func (l Load) Score() float64 { return float64(l.Forwards)*100 + l.MemPct + l.NetPct }
// Task is a PENDING forward request circulated in the token. It carries the
// intermediate forwarding intent (local/remote/link) — NOT a rendered frpc
@ -84,7 +93,6 @@ type State struct {
PendingTasks map[string]*Task `json:"pendingTasks,omitempty"`
// Topology: active forwards owned by members (full cluster view).
Topology map[string]*TopoEntry `json:"topology,omitempty"`
Seq int64 `json:"seq"`
}
// Token is the circulating message: one physical token per cycle (single
@ -207,8 +215,42 @@ func (s *State) LowestAlive() *Node {
}
func (s *State) NextTaskID() string {
s.Seq++
return fmt.Sprintf("t%d", s.Seq)
// Collision-free id allocation by scanning the ids actually in flight
// (pending + topology) and taking max+1. The ring is mutually exclusive
// (one token holder at a time), so when a node mints an id it has the
// authoritative full view — scanning existing ids guarantees a fresh id.
// n is tiny (handful of forwards). No Seq counter is needed: a cross-node
// Seq was previously adopted wholesale on every OnToken (e.state = tk.State),
// which dropped local increments and could regress below an id still in use,
// recycling it and overwriting an active topology entry.
max := int64(0)
for id := range s.PendingTasks {
if n := taskIDNum(id); n > max {
max = n
}
}
for _, e := range s.Topology {
if n := taskIDNum(e.TaskID); n > max {
max = n
}
}
return fmt.Sprintf("t%d", max+1)
}
// taskIDNum extracts the numeric suffix of a task id "t12" -> 12 (0 if it does
// not parse). Used only to keep NextTaskID collision-free.
func taskIDNum(id string) int64 {
if len(id) < 2 || id[0] != 't' {
return 0
}
var n int64
for _, c := range id[1:] {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int64(c-'0')
}
return n
}
// AddRemoveNode publishes a node-removal command via the token; the target

View File

@ -4,6 +4,7 @@ package cluster
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
@ -30,6 +31,20 @@ type Engine struct {
Cache []string
Handler Handler
// nodeKey is this node's cluster admission key. A newcomer must present
// the sponsor's nodeKey (as JoinInfo.JoinKey) to join via it. Persisted
// in store.Settings; stable across restarts so -join-key stays valid.
// Generated lazily: on CreateCluster (creator) or AdoptState (joiner),
// NOT at startup — a fresh node that hasn't created/joined has no key.
// Cleared on detachAsStandalone (leaving the cluster invalidates the key).
nodeKey string
keyPersist func(key string) error // persists nodeKey to store (nil in tests)
// peerPersist saves the cached peer list (JSON of [{addr,key},...]) so a
// crashed node can auto-rejoin on restart via any cached peer. Called on
// every token cycle (OnToken) and on AdoptState. Cleared (pass "") on
// detachAsStandalone — an explicit leave must NOT auto-rejoin.
peerPersist func(peersJSON string) error
state State
// myAddr maps our Node ID to the address peers dial.
myAddr string
@ -81,10 +96,11 @@ type Engine struct {
// NewEngine builds the engine; state holds this node as initial leader unless
// a peer list says otherwise (creation node starts the ring).
func NewEngine(id, addr, user, pass, version string, cache []string, h Handler, send func(ctx context.Context, next string, tk *Token) error, selfAddr string, isLeader bool) *Engine {
func NewEngine(id, addr, user, pass, version string, cache []string, h Handler, send func(ctx context.Context, next string, tk *Token) error, selfAddr string, isLeader bool, nodeKey string) *Engine {
e := &Engine{
ID: id, Addr: addr, User: user, Pass: pass,
Version: version, Cache: cache, Handler: h,
nodeKey: nodeKey,
state: State{
LeaderID: "",
Cycle: 0,
@ -99,7 +115,7 @@ func NewEngine(id, addr, user, pass, version string, cache []string, h Handler,
published: map[string]struct{}{},
}
n := Node{ID: id, Addr: selfAddr, Alive: true, IsLeader: isLeader,
Load: Load{MemPct: 10, NetPct: 10}, Version: version, Cache: cache}
Load: Load{MemPct: 10, NetPct: 10}, Version: version, Cache: cache, NodeKey: nodeKey}
e.state.UpsertNode(n)
return e
}
@ -121,12 +137,21 @@ func (e *Engine) myNode() Node {
return e.state.Nodes[i]
}
// loadSnapshot reads our runtime load (mem+net) from the handler.
// loadSnapshot reads our runtime load. The primary signal is the count of
// forwards this node currently owns (Forwards) — this is what makes the
// lowest-load claim actually distribute tasks across nodes instead of the
// leader hogging every claimable task in a single token pass (its stored
// load was never refreshed between claims, so it stayed "lowest"). mem/net
// from the handler only break ties at equal forward count.
func (e *Engine) loadSnapshot() Load {
l := Load{Forwards: len(e.state.ForwardsOwnedBy(e.ID))}
if e.Handler != nil {
return e.Handler.RuntimeLoad()
b := e.Handler.RuntimeLoad()
l.MemPct, l.NetPct = b.MemPct, b.NetPct
} else {
l.MemPct, l.NetPct = 20, 20
}
return Load{MemPct: 20, NetPct: 20}
return l
}
// OnToken is the SINGLE-ROUND token handler. Per the authoritative design
@ -153,7 +178,15 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
log.Printf("ring[%s] OnToken cycle=%d", e.ID, tk.Cycle)
// Parallel rhythm timer: operations run while the pace clock ticks.
rhythm := time.NewTimer(ringHopDelay)
// Delay scales with alive node count (more nodes → lower per-hop delay,
// keeping the round time ~constant for real-time sync).
alive := 0
for i := range tk.State.Nodes {
if tk.State.Nodes[i].Alive {
alive++
}
}
rhythm := time.NewTimer(hopDelayFor(alive))
defer rhythm.Stop()
// (a) ADOPT the cluster picture. Incoming state is authoritative: joins
@ -219,7 +252,7 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
ID: e.ID, Addr: e.myAddr, Alive: true,
IsLeader: e.state.LeaderID == e.ID,
Load: e.loadSnapshot(),
Version: e.Version, Cache: e.Cache,
Version: e.Version, Cache: e.Cache, NodeKey: e.nodeKey,
})
}
@ -243,6 +276,8 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
// Publish our consolidated state back into the token.
tk.State = e.state
e.lastSyncAt = time.Now().Unix()
// Persist the cached peer list so a crash/restart can auto-rejoin.
e.persistPeers()
// Record the tasks leaving on this token so their absence from the next
// incoming token is recognized as "consumed downstream" rather than
// "never sent" — otherwise the localPending re-merge above would
@ -352,16 +387,33 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
}
}
}
wasLeader := e.state.LeaderID == e.ID
if succ, ok := e.state.AliveSuccessor(e.ID); ok && succ != e.ID {
e.removedNext = succ
}
e.state.SelfRemove(e.ID)
e.selfRemoved = true
// Leader failover: if we were the leader, the ring would run
// leaderless after our departure — LeaderID would be "" (SelfRemove
// clears it), no node would call Send (cycle never advances), and
// WatchLeader can't find AlivePredecessor("") to promote a
// successor. Designate the captured successor as the new leader
// so the token carries a valid LeaderID downstream; the successor
// then calls Send on its turn and the ring keeps cycling.
if wasLeader && e.removedNext != "" {
e.state.LeaderID = e.removedNext
for i := range e.state.Nodes {
e.state.Nodes[i].IsLeader = e.state.Nodes[i].ID == e.removedNext
}
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.removedNext})
}
}
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogNodeLeave, map[string]string{"node": e.ID})
}
log.Printf("ring[%s] self-removed from cluster (token command %s); next=%s",
e.ID, claimed.ID, e.removedNext)
log.Printf("ring[%s] self-removed from cluster (token command %s); next=%s leader=%s",
e.ID, claimed.ID, e.removedNext, e.state.LeaderID)
continue
}
if claimed.RemoveNode != "" {
@ -376,6 +428,20 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
e.ID, claimed.ID, claimed.RemoveNode)
continue
}
// Defense-in-depth against duplicate claims: if an active topology
// entry for this forward already exists (owned by us or another
// node), this task is a stale resurrected copy or a multi-token
// collision — drop it WITHOUT spawning, so we never end up with an
// orphaned worker running a forward the topology attributes to a
// different node. Safe for OfflineReassign: that path deletes the
// topology entry BEFORE re-queueing, so TopologyOwner returns "" and
// the legitimate re-claim passes through.
if owner := e.state.TopologyOwner(claimed); owner != "" {
log.Printf("ring[%s] drop duplicate task %s: %s→%s:%d already owned by %s",
e.ID, claimed.ID, claimed.Local.Name, claimed.Remote.Name,
claimed.Link.RemotePort, owner)
continue
}
if e.Handler != nil {
if err := e.Handler.Claim(ctx, claimed); err != nil {
e.state.PendingTasks[claimed.ID] = claimed
@ -389,6 +455,13 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
})
}
e.state.AddTopology(claimed, e.ID)
// Refresh our own stored load so the next selfIsLowest check in this
// same pass sees the incremented Forwards count — otherwise we'd keep
// claiming (stored load stays stale until we forward the token) and
// hog every claimable task, defeating lowest-load distribution.
if i := e.state.Find(e.ID); i >= 0 {
e.state.Nodes[i].Load = e.loadSnapshot()
}
}
return nil
}
@ -397,29 +470,72 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
// It is the transport hook used by the HTTP handler after OnToken. If this
// node just self-removed, its ID is no longer in state.Nodes so
// AliveSuccessor would fail — use the original successor captured before
// removal (plan §移除节点 step 3: "令牌传递给自身原本的下一家").
// removal (plan §移除节点 step 3: "令牌传递给自身原本的下一家"). Otherwise
// delegates to forwardToNext which handles send-failure → mark offline →
// reassign → try next hop (plan §故障自幽).
func (e *Engine) Forward(ctx context.Context, tk *Token) error {
if e.removedNext != "" {
next := e.removedNext
e.removedNext = ""
var err error
if e.send != nil {
log.Printf("ring[%s] forward (self-removed) cycle=%d to %s", e.ID, tk.Cycle, next)
return e.send(ctx, next, tk)
err = e.send(ctx, next, tk)
}
return nil
// After handing off the token to the old successor, detach to a
// fresh standalone state so Snapshot() no longer serves the old
// cluster picture (members, topology, log) after self-leave. The
// token already carries the published state (with the new leader if
// we designated one); the local reset does not affect the sent token.
if e.selfRemoved {
e.detachAsStandalone()
}
return err
}
next, ok := e.state.AliveSuccessor(e.ID)
if !ok {
return nil // single-node ring
return e.forwardToNext(ctx, tk)
}
// detachAsStandalone resets the engine to a fresh standalone state after a
// self-leave has completed (the token was handed to the old successor). This
// prevents Snapshot() from serving the old cluster picture — other members,
// the full topology, pending tasks, and the cluster log — after the node has
// permanently left the ring. Equivalent to CreateCluster minus the IsMember
// guard (we are already detached) plus a fresh log (old cluster events stale).
func (e *Engine) detachAsStandalone() {
// Leaving the cluster invalidates this node's admission key — a
// standalone node has no key until it creates/joins again. Clear both
// the in-memory key and the persisted copy (so a restart doesn't
// resurrect a stale key for a node that's no longer in any cluster).
e.nodeKey = ""
if e.keyPersist != nil {
_ = e.keyPersist("")
}
if next == e.ID {
return nil // never forward to ourselves
// Clear the cached peer list so this node does NOT auto-rejoin on
// restart — it explicitly left the cluster.
e.clearPeers()
e.state = State{
LeaderID: e.ID,
PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{},
RoundDelay: 2 * time.Second,
}
if e.send != nil {
log.Printf("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next)
return e.send(ctx, next, tk)
e.state.UpsertNode(Node{
ID: e.ID, Addr: e.myAddr, Alive: true, IsLeader: true,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache, NodeKey: e.nodeKey,
})
e.selfRemoved = false
e.removedNext = ""
e.lastRingStart = time.Time{}
e.lastTokenAt = 0
e.lastSyncAt = 0
e.inflight.clear()
e.failCount = map[string]int{}
e.published = map[string]struct{}{}
if e.Log != nil {
e.Log = NewClusterLog()
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.ID})
}
return nil
log.Printf("ring[%s] detached to standalone after self-leave", e.ID)
}
// Snapshot returns a serializable view of the ring for the frontend.
@ -433,6 +549,9 @@ type RingSnapshot struct {
Pending []*Task `json:"pending"`
Topology []*TopoEntry `json:"topology"`
Log []LogEntry `json:"log,omitempty"`
// NodeKey: this node's cluster admission key. The cluster page displays it
// so the operator can copy it for newcomers joining via this node.
NodeKey string `json:"nodeKey,omitempty"`
}
func (e *Engine) Snapshot() *RingSnapshot {
@ -445,6 +564,7 @@ func (e *Engine) Snapshot() *RingSnapshot {
Nodes: e.state.Nodes,
Pending: e.state.PendingList(),
Topology: e.state.TopologyList(),
NodeKey: e.nodeKey,
}
if e.Log != nil {
snap.Log = e.Log.Snapshot()
@ -509,6 +629,10 @@ type JoinInfo struct {
Addr string `json:"addr"`
Version string `json:"version,omitempty"`
Cache []string `json:"cache,omitempty"`
// JoinKey is the sponsor's nodeKey — the newcomer must present it to
// prove it is authorized to join via the sponsor. The sponsor verifies
// ji.JoinKey == e.nodeKey; mismatch → 403.
JoinKey string `json:"joinKey,omitempty"`
}
// injectPendingJoin writes every queued newcomer into state right after
@ -572,10 +696,17 @@ func (e *Engine) AdoptState(s State) {
e.state.PendingTasks[id] = t
}
e.state.UpsertNode(Node{ID: e.ID, Addr: e.myAddr, Alive: true,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache})
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache, NodeKey: e.nodeKey})
if e.Log != nil {
e.Log.Append(e.ID, LogNodeJoin, map[string]string{"node": e.ID, "addr": e.myAddr})
}
// Newcomer generates its own admission key after joining, so future
// nodes can join via it. Per the user's design: "每个节点加入集群后
// 生成自身密钥". A node that already has a persisted key (restart
// re-join) keeps it.
e.ensureNodeKey()
// Persist the cached peer list from the adopted ring state.
e.persistPeers()
}
// CreateCluster reseeds this node as a fresh standalone leader (single-node
@ -588,17 +719,17 @@ func (e *Engine) CreateCluster() error {
if e.IsMember() {
return fmt.Errorf("node is a multi-node cluster member; leave first")
}
e.ensureNodeKey()
e.state = State{
LeaderID: e.ID,
Cycle: 0,
PendingTasks: map[string]*Task{},
Topology: map[string]*TopoEntry{},
RoundDelay: 2 * time.Second,
Seq: e.state.Seq,
}
e.state.UpsertNode(Node{
ID: e.ID, Addr: e.myAddr, Alive: true, IsLeader: true,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache,
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache, NodeKey: e.nodeKey,
})
e.selfRemoved = false
e.removedNext = ""
@ -652,6 +783,80 @@ func (e *Engine) HasTask(local, remote string, port int) bool {
// IsLeader reports whether this node is the current ring leader.
func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID }
// NodeKey returns this node's cluster admission key (for the frontend to
// display so the operator can copy it for newcomers).
func (e *Engine) NodeKey() string { return e.nodeKey }
// SetKeyPersist installs the callback used to persist the nodeKey to durable
// storage (store.SetNodeKey). Called once from main.go after NewEngine. Tests
// leave it nil — ensureNodeKey still generates the key in-memory.
func (e *Engine) SetKeyPersist(fn func(key string) error) { e.keyPersist = fn }
// SetPeerPersist installs the callback used to persist the cached peer list
// to durable storage (store.SetClusterPeers). Called once from main.go.
func (e *Engine) SetPeerPersist(fn func(peersJSON string) error) { e.peerPersist = fn }
// persistPeers extracts all alive peers (addr + nodeKey, excluding self)
// from the current ring state and persists them via the peerPersist callback.
// Called on every token cycle (OnToken) and on AdoptState so a crashed node
// always has the latest peer list to rejoin through. Skipped for standalone
// (single-node) rings — a standalone node has no peers to cache.
func (e *Engine) persistPeers() {
if e.peerPersist == nil {
return
}
type peerEntry struct {
Addr string `json:"addr"`
Key string `json:"key"`
}
var peers []peerEntry
for _, n := range e.state.Nodes {
if n.ID == e.ID || !n.Alive {
continue
}
if n.Addr == "" || n.NodeKey == "" {
continue
}
peers = append(peers, peerEntry{Addr: n.Addr, Key: n.NodeKey})
}
if len(peers) == 0 {
return // standalone or all-offline: don't overwrite a good cache
}
data, err := json.Marshal(peers)
if err != nil {
return
}
if err := e.peerPersist(string(data)); err != nil {
log.Printf("ring[%s] persist cluster peers failed: %v", e.ID, err)
}
}
// clearPeers wipes the cached peer list (called from detachAsStandalone so
// an explicit leave does NOT auto-rejoin on restart).
func (e *Engine) clearPeers() {
if e.peerPersist != nil {
_ = e.peerPersist("")
}
}
// ensureNodeKey generates a random admission key if this node doesn't have one
// yet, and persists it via the keyPersist callback (so it survives restarts).
// Called from CreateCluster (the creator generates a key so others can join
// via it) and AdoptState (a newcomer generates its own key after joining, so
// future nodes can join via it). Per the user's design: "每个节点加入集群后
// 生成自身密钥" — the key is born with cluster membership, not at startup.
func (e *Engine) ensureNodeKey() {
if e.nodeKey != "" {
return
}
e.nodeKey = GenerateNodeKey()
if e.keyPersist != nil {
if err := e.keyPersist(e.nodeKey); err != nil {
log.Printf("ring[%s] persist nodeKey failed: %v", e.ID, err)
}
}
}
// IsMember reports whether this node is currently an active multi-node member
// (self is in the ring alongside others). Used by the create/join gates to
// refuse actions that would split an active ring. A detached node (self not

View File

@ -3,6 +3,8 @@ package cluster
import (
"context"
"testing"
"webui4frpc/internal/store"
)
type fakeHandler struct {
@ -33,7 +35,7 @@ func sendNull(ctx context.Context, next string, tk *Token) error { return nil }
// task gets claimed (lowest load = only node).
func TestSingleNodeCycle(t *testing.T) {
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", []string{"0.71.0"},
&fakeHandler{load: Load{MemPct: 30, NetPct: 30}}, sendNull, "n1:7500", true)
&fakeHandler{load: Load{MemPct: 30, NetPct: 30}}, sendNull, "n1:7500", true, "")
eng.StartRing(context.Background())
// single node: token stays local; no successor so nothing travels.
@ -46,7 +48,7 @@ func TestSingleNodeCycle(t *testing.T) {
// stamps; then phase flips and second round syncs.
func TestTwoNodesSingleRound(t *testing.T) {
eng2 := NewEngine("n2", "n2:7500", "u", "p", "0.71.0", nil,
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendNull, "n2:7500", false)
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendNull, "n2:7500", false, "")
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 50, NetPct: 50}})
// n1 sends a token carrying the cluster picture; single-round processing:
@ -69,3 +71,119 @@ func TestTwoNodesSingleRound(t *testing.T) {
t.Fatal("nil token after processing")
}
}
// TestLeaderRoundTripNoResurrection: when the leader submits a task and the
// token completes a full round (leader→n2 claims→n3→leader), the consumed task
// MUST NOT be resurrected on the leader's next OnToken, and the topology must
// still attribute the forward to the single claimer (n2).
//
// This holds NOT via a published-set mark on StartRing, but via Go map
// reference semantics: StartRing sets tk.State = e.state, so the token's
// PendingTasks map IS the leader's own map. When n2 calls ClaimPending it
// deletes from that shared map — the deletion is visible to the leader too.
// By the time the token returns, the claimed task is already gone from the
// leader's e.state.PendingTasks, so the localPending re-merge has nothing to
// re-inject. Single token + remove-on-claim = single claim (plan §M6).
func TestLeaderRoundTripNoResurrection(t *testing.T) {
// 3-node ring: n1 (leader+submitter), n2 (lowest, claims), n3 (idle).
var sent *Token
sendCap := func(ctx context.Context, next string, tk *Token) error {
sent = tk
return nil
}
n1 := NewEngine("n1", "n1:7500", "u", "p", "v", nil,
&fakeHandler{load: Load{MemPct: 50, NetPct: 50}}, sendCap, "n1:7500", true, "")
// n2/n3 carry a lower stored load than n1's NewEngine default ({10,10})
// so LowestAlive picks n2 (first among the tied low nodes) as the claimer.
n1.state.UpsertNode(Node{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 1, NetPct: 1}})
n1.state.UpsertNode(Node{ID: "n3", Addr: "n3:7500", Alive: true, Load: Load{MemPct: 1, NetPct: 1}})
task := n1.SubmitTask(store.Local{Name: "l1"}, store.Remote{Name: "r1"}, store.Link{RemotePort: 100})
if task == nil {
t.Fatal("submit returned nil")
}
n1.StartRing(context.Background())
if sent == nil {
t.Fatal("StartRing did not send a token")
}
// n2 receives, claims t1 (lowest load), establishes topology.
n2 := NewEngine("n2", "n2:7500", "u", "p", "v", nil,
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendCap, "n2:7500", false, "")
out2, err := n2.OnToken(context.Background(), sent)
if err != nil {
t.Fatal(err)
}
if got := len(n2.state.TopologyList()); got != 1 {
t.Fatalf("n2 should own 1 forward, topo=%+v", n2.state.TopologyList())
}
if len(n2.state.PendingList()) != 0 {
t.Fatalf("pending should be empty after n2 claim, got %+v", n2.state.PendingList())
}
// n3 receives, nothing to claim, forwards.
n3 := NewEngine("n3", "n3:7500", "u", "p", "v", nil,
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendCap, "n3:7500", false, "")
out3, err := n3.OnToken(context.Background(), out2)
if err != nil {
t.Fatal(err)
}
// Token returns to n1. The consumed task t1 MUST NOT be resurrected.
if _, err := n1.OnToken(context.Background(), out3); err != nil {
t.Fatal(err)
}
if got := len(n1.state.PendingList()); got != 0 {
t.Fatalf("n1 resurrected a consumed task: pending=%+v (single-token + shared-map must prevent this)",
n1.state.PendingList())
}
// Topology still attributes t1 to n2 (not overwritten by a re-claim).
topo := n1.state.TopologyList()
if len(topo) != 1 || topo[0].OwnerID != "n2" {
t.Fatalf("topology should be t1@n2, got %+v", topo)
}
}
// TestClaimGuardDropsDuplicateForward: when a node (lowest load) receives a
// pending task whose forward is ALREADY in the topology owned by another node
// (a resurrected/stale copy), the claim path must drop it WITHOUT spawning a
// worker or overwriting the topology. Without the guard a second worker for
// the same forward would be spawned and orphaned (the topology entry is keyed
// by task id, so AddTopology would silently overwrite the owner).
func TestClaimGuardDropsDuplicateForward(t *testing.T) {
// n1 is lowest load and would otherwise claim; the fake Claim hook fails
// the test if ever called.
n1 := NewEngine("n1", "n1:7500", "u", "p", "v", nil,
&fakeHandler{
load: Load{MemPct: 10, NetPct: 10},
claim: func(ctx context.Context, tk *Task) error {
t.Fatalf("Claim must not be called for an already-owned forward: %s", tk.ID)
return nil
},
}, sendNull, "n1:7500", true, "")
dup := &Task{ID: "t1", Local: store.Local{Name: "l1"}, Remote: store.Remote{Name: "r1"}, Link: store.Link{RemotePort: 100}}
tk := &Token{Cycle: 1, State: State{
Nodes: []Node{
{ID: "n1", Addr: "n1:7500", Alive: true, Load: Load{MemPct: 10, NetPct: 10}},
{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 50, NetPct: 50}},
},
PendingTasks: map[string]*Task{"t1": dup},
Topology: map[string]*TopoEntry{"t1": {
TaskID: "t1", OwnerID: "n2", Local: store.Local{Name: "l1"},
Remote: store.Remote{Name: "r1"}, Link: store.Link{RemotePort: 100}, Active: true,
}},
}}
if _, err := n1.OnToken(context.Background(), tk); err != nil {
t.Fatal(err)
}
// Pending drained (the stale task was consumed/dropped, not left to ride).
if got := len(n1.state.PendingList()); got != 0 {
t.Fatalf("stale task should be dropped, pending=%+v", n1.state.PendingList())
}
// Topology untouched: still owned by n2.
topo := n1.state.TopologyList()
if len(topo) != 1 || topo[0].OwnerID != "n2" {
t.Fatalf("topology should remain t1@n2, got %+v", topo)
}
}

View File

@ -12,11 +12,29 @@ import (
"time"
)
// ringHopDelay paces each token hop (real-world cadence, avoids busy-loop).
// 500ms keeps a 3-node ring well under the LossTimeout floor (2500ms) so a
// healthy round is never misjudged lost, while not idly burning CPU/HTTP at
// 20Hz like the old 50ms did.
const ringHopDelay = 500 * time.Millisecond
// Per-hop pacing bounds. The hop delay scales DOWN as the ring grows so the
// round time stays ~ringHopDelayMax regardless of node count — a static
// 500ms made large clusters slow (3 nodes=1.5s, 5 nodes=2.5s, 10 nodes=5s
// per round); now 3/5/10 nodes all round at ~500ms (until the floor bites),
// keeping sync real-time without a token storm (round freq ≈2Hz).
const (
ringHopDelayMax = 500 * time.Millisecond
ringHopDelayMin = 50 * time.Millisecond
)
// hopDelayFor returns the per-hop pace for a ring of aliveNodes members.
// Nodes越多延迟越低: delay = ringHopDelayMax / aliveNodes, floored at min.
// n=2→250ms, n=3→167ms, n=5→100ms, n=10→50ms(floor) — round time ≈500ms.
func hopDelayFor(aliveNodes int) time.Duration {
if aliveNodes < 2 {
aliveNodes = 2
}
d := ringHopDelayMax / time.Duration(aliveNodes)
if d < ringHopDelayMin {
d = ringHopDelayMin
}
return d
}
// LossTimeout is the token-loss threshold per design: roundDelay/2 + 20ms,
// floored so a healthy fast ring is never misjudged.
@ -92,8 +110,14 @@ func (e *Engine) Send(ctx context.Context, tk *Token) error {
return e.forwardToNext(ctx, tk)
}
// forwardToNext sends the token to the next alive successor; on failure it
// marks that node offline, reattaches its tasks, and tries the next hop.
// forwardToNext sends the token to the next alive successor. On send
// failure (no receipt within the HTTP timeout = neighbor unreachable),
// the normal node-death procedure fires: mark offline, reassign the dead
// node's tasks, and try the next hop. If the dead node was the leader,
// the predecessor reuses the same procedure and additionally promotes
// itself to leader + starts a fresh cycle (the token destined for the
// dead leader is lost; a new cycle must begin). This is the PRIMARY
// leader-death detection path per plan §故障自幽 + §leader 补充/监控.
func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
for hops := 0; hops < len(e.state.Nodes); hops++ {
next, ok := e.nextRecipient()
@ -104,26 +128,32 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
if e.send == nil {
return nil
}
log.Printf("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next)
err := e.send(ctx, next, tk)
if err == nil {
if e.state.LeaderID == e.ID {
e.inflight.mark(e.state.RoundDelay)
}
// Pace the ring so tokens circulate at a realistic cadence.
// Pacing handled by the parallel rhythm timer in OnToken.
return nil
}
log.Printf("ring[%s] send to %s failed: %v", e.ID, next, err)
// Do not declare a neighbor offline on a single timeout: transient
// send failures (network jitter, busy handler) must not break the
// ring. Only after consecutive failures do we evict the node.
e.failMu.Lock()
e.failCount[next]++
if e.failCount[next] >= 2 {
e.state.MarkOffline(next)
e.state.OfflineReassign(next)
delete(e.failCount, next)
// Send failed = no receipt within timeout = neighbor offline.
// Normal node-death: mark offline, reassign tasks to pending.
log.Printf("ring[%s] send to %s failed (no receipt): %v", e.ID, next, err)
e.state.MarkOffline(next)
e.state.OfflineReassign(next)
// If the dead node was the leader, promote self and start a new
// cycle. The token was going to the leader; with the leader dead
// the token is lost — start fresh as the new leader (plan §leader
// 补充/监控: "上家邻居探测到 leader 崩溃 → 自身成为新 leader").
if next == e.state.LeaderID {
e.becomeLeader()
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.ID})
}
e.StartRing(ctx)
return nil
}
// Non-leader neighbor death: continue to the next recipient.
}
e.inflight.clear()
return nil
@ -138,10 +168,26 @@ func (e *Engine) becomeLeader() {
log.Printf("ring[%s] promoted to leader", e.ID)
}
// WatchLeader runs the leader liveness monitor: the predecessor of the leader
// pings it; on failure it marks the leader offline and promotes itself.
// WatchLeader runs the FALLBACK leader liveness monitor. The PRIMARY path is
// forwardToNext: when the predecessor sends a token to the leader and the send
// fails (leader's HTTP server down), forwardToNext marks the leader offline and
// promotes self. WatchLeader covers the case forwardToNext CANNOT detect:
// the leader received the token (POST returned 200) but then crashed/restarted/
// detached before forwarding it — the send succeeded, so forwardToNext sees no
// error. In this case the predecessor pings the leader; if the leader is down
// (connection refused) or restarted/detached (standalone → 409), the heartbeat
// fails and the predecessor takes over.
//
// The predecessor role is NOT permanent — it shifts as the ring topology
// changes (nodes join/leave). Each tick re-evaluates AlivePredecessor(LeaderID)
// so the correct node monitors the leader at all times. Per design:
// "上邻居也不是永久的,也要有普通节点按照令牌传递的拓扑变换转换为上邻居的逻辑".
//
// Interval = 1s so worst-case detection (tick + 1.5s ping timeout ≈ 2.5s)
// aligns with LossTimeout (roundDelay/2 + 20ms, floored at 2500ms), per design:
// "与leader超时重发时间一致".
func (e *Engine) WatchLeader(ctx context.Context) {
tick := time.NewTicker(2 * time.Second)
tick := time.NewTicker(1 * time.Second)
defer tick.Stop()
for {
select {
@ -164,6 +210,11 @@ func (e *Engine) WatchLeader(ctx context.Context) {
e.state.MarkOffline(e.state.LeaderID)
e.state.OfflineReassign(e.state.LeaderID)
e.becomeLeader()
// Kick off a fresh cycle: the ring died with the old
// leader (no token inflight → WatchTokenLoss won't
// fire). Without this the newly promoted leader would
// sit idle and the ring would stay dead.
e.StartRing(ctx)
}
}
}

View File

@ -12,7 +12,7 @@ func newTestEngine(id string, isLeader bool) *Engine {
return NewEngine(id, id+":7500", "u", "p", "0.1.0", nil,
&fakeHandler{load: Load{MemPct: 20, NetPct: 20}},
func(ctx context.Context, next string, tk *Token) error { return nil },
id+":7500", isLeader)
id+":7500", isLeader, "")
}
// TestLogAppendDeltaReplay: append entries, extract delta after a watermark,

View File

@ -104,7 +104,7 @@ func TestRemoveCommandFulfilledNoPhantom(t *testing.T) {
eng := NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
claim: func(ctx context.Context, tk *Task) error { claimed = append(claimed, tk); return nil }},
sendNull, "n1:7500", true)
sendNull, "n1:7500", true, "")
rm := eng.state.AddRemoveNode("node-x:7500") // target not in the ring
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
t.Fatal(err)
@ -119,3 +119,102 @@ func TestRemoveCommandFulfilledNoPhantom(t *testing.T) {
t.Fatalf("phantom topology entry created: %+v", eng.state.TopologyList())
}
}
// TestLeaderSelfRemoveDesignatesSuccessor: when the LEADER self-removes, it
// must designate its successor as the new leader before publishing the token.
// Without this, LeaderID would be "" (SelfRemove clears it), no node would
// call Send (cycle never advances), and WatchLeader can't find
// AlivePredecessor("") to promote anyone — the ring runs leaderless and dies.
func TestLeaderSelfRemoveDesignatesSuccessor(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.InsertAfter("n1", Node{
ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 90, NetPct: 90},
})
// n1 (leader) publishes a remove-node command for itself.
rm := eng.state.AddRemoveNode("n1")
eng.state.PendingTasks = map[string]*Task{rm.ID: rm}
out, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state})
if err != nil {
t.Fatal(err)
}
if out == nil {
t.Fatal("nil token after OnToken")
}
// n1 removed itself from the ring.
if eng.state.Find("n1") >= 0 {
t.Fatalf("n1 still in ring: %+v", eng.state.Nodes)
}
// n2 designated as the new leader (not "" — the old bug).
if eng.state.LeaderID != "n2" {
t.Fatalf("LeaderID = %q want n2 (successor should be designated as leader)", eng.state.LeaderID)
}
// n2 marked IsLeader in Nodes.
if i := eng.state.Find("n2"); i >= 0 && !eng.state.Nodes[i].IsLeader {
t.Fatalf("n2 not marked IsLeader: %+v", eng.state.Nodes[i])
}
// removedNext captured so Forward can hand the token to the old successor.
if eng.removedNext != "n2" {
t.Fatalf("removedNext = %q want n2", eng.removedNext)
}
}
// TestDetachAfterForward: after self-leave + Forward (token handed to the old
// successor), the engine resets to a fresh standalone state so Snapshot() no
// longer serves the old cluster picture (members, topology, pending, log).
func TestDetachAfterForward(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.InsertAfter("n1", Node{
ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 90, NetPct: 90},
})
// Give n1 an owned forward so the old state has a non-empty topology.
p := eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
eng.state.ClaimPending(p.ID)
eng.state.AddTopology(p, "n1")
rm := eng.state.AddRemoveNode("n1")
eng.state.PendingTasks = map[string]*Task{rm.ID: rm}
out, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state})
if err != nil {
t.Fatal(err)
}
if out == nil {
t.Fatal("nil token after OnToken")
}
// Before Forward: self-removed, removedNext captured, old state retained
// (n2 still in Nodes, n1's forward re-queued as pending by SelfRemove).
if !eng.selfRemoved {
t.Fatal("selfRemoved not set before Forward")
}
if eng.removedNext != "n2" {
t.Fatalf("removedNext = %q want n2", eng.removedNext)
}
if eng.state.Find("n2") < 0 {
t.Fatal("n2 (old member) missing before Forward — test setup wrong")
}
// Forward hands off the token (sendNull no-op) then detaches to standalone.
if err := eng.Forward(context.Background(), out); err != nil {
t.Fatal(err)
}
// After Forward: fresh standalone state.
if eng.state.LeaderID != "n1" {
t.Fatalf("LeaderID = %q want n1 (standalone leader after detach)", eng.state.LeaderID)
}
if len(eng.state.Nodes) != 1 || eng.state.Nodes[0].ID != "n1" {
t.Fatalf("state not standalone (want just n1): %+v", eng.state.Nodes)
}
if len(eng.state.Topology) != 0 {
t.Fatalf("topology not cleared after detach: %+v", eng.state.Topology)
}
if len(eng.state.PendingTasks) != 0 {
t.Fatalf("pending not cleared after detach: %+v", eng.state.PendingList())
}
if eng.selfRemoved {
t.Fatal("selfRemoved should be cleared after detach")
}
if eng.removedNext != "" {
t.Fatalf("removedNext should be empty after detach, got %q", eng.removedNext)
}
}

View File

@ -15,7 +15,7 @@ func TestRevokeTaskRemovesTopology(t *testing.T) {
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
revoke: func(ctx context.Context, tk *Task) error { revoked = true; return nil }},
func(ctx context.Context, next string, tk *Token) error { return nil },
"n1:7500", true)
"n1:7500", true, "")
// establish a forward
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})

View File

@ -6,12 +6,23 @@ package cluster
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"time"
)
// GenerateNodeKey returns a random 16-byte hex string for use as a cluster
// admission key. Called on first startup when no key is persisted yet.
func GenerateNodeKey() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return fmt.Sprintf("%x", b)
}
// JoinRing asks target to add us to its ring and returns the adopted state.
func (e *Engine) JoinRing(ctx context.Context, targetAddr string, ji JoinInfo) error {
url := fmt.Sprintf("http://%s/api/manager/cluster/join", targetAddr)
@ -49,9 +60,10 @@ func (e *Engine) JoinRing(ctx context.Context, targetAddr string, ji JoinInfo) e
// JoinRingAddr wraps JoinRing for HTTP handlers: it builds the newcomer's
// JoinInfo from this node's own id/addr/version/cache (so the caller doesn't
// touch the engine's unexported fields) and asks targetAddr to sponsor us
// into its ring. This is the runtime "加入集群" path (vs. the startup
// into its ring. joinKey is the sponsor's nodeKey — the sponsor verifies it
// before admitting. This is the runtime "加入集群" path (vs. the startup
// bootstrap call in cmd/webui4frpc/main.go).
func (e *Engine) JoinRingAddr(ctx context.Context, targetAddr string) error {
ji := JoinInfo{ID: e.ID, Addr: e.myAddr, Version: e.Version, Cache: e.Cache}
func (e *Engine) JoinRingAddr(ctx context.Context, targetAddr, joinKey string) error {
ji := JoinInfo{ID: e.ID, Addr: e.myAddr, Version: e.Version, Cache: e.Cache, JoinKey: joinKey}
return e.JoinRing(ctx, targetAddr, ji)
}

129
internal/httpapi/auth.go Normal file
View File

@ -0,0 +1,129 @@
package httpapi
import (
"context"
"crypto/subtle"
"net/http"
"strings"
"webui4frpc/internal/store"
)
// identity is the authenticated principal for a request, stashed in the
// request context so handlers can read who/what level the caller is.
//
// Type is one of:
// - "user": a row in the users table, authenticated via Basic + bcrypt.
// - "service": the -user/-password flag creds (inter-node cluster traffic
// and bootstrap), authenticated via a constant-time compare.
// - "key": an API key, authenticated via Bearer + sha256 lookup.
//
// Level is the effective access tier: "read" | "write" | "admin". For users it
// derives from the role (viewer -> read, admin -> admin); for keys it is the
// key's scope; for the service identity it is always admin (the flags are the
// built-in operator).
type identity struct {
Type string `json:"type"` // "user" | "service" | "key"
Name string `json:"name"` // username / flag user / key label
Level string `json:"level"` // "read" | "write" | "admin"
UserID int64 `json:"userId"` // users.id for Type=="user"|"key"
}
type ctxKey struct{}
// identityFrom returns the authenticated identity from the request context,
// or a zero identity (Level=="") when unauthenticated.
func identityFrom(r *http.Request) identity {
if v, ok := r.Context().Value(ctxKey{}).(identity); ok {
return v
}
return identity{}
}
// levelRank orders the access tiers: read < write < admin. Unknown = 0.
func levelRank(level string) int {
switch level {
case "read":
return 1
case "write":
return 2
case "admin":
return 3
}
return 0
}
// roleLevel maps a user role to an access level (viewer -> read, admin -> admin).
func roleLevel(role string) string {
if role == "admin" {
return "admin"
}
return "read"
}
// hasLevel reports whether the request's identity meets minLevel. Handlers
// serving both read and mutating methods on one path use this to gate the
// mutating branch (the route itself is registered at the read level so that
// viewer GETs succeed, then PUT/POST/DELETE branches re-check here).
func hasLevel(r *http.Request, minLevel string) bool {
return levelRank(identityFrom(r).Level) >= levelRank(minLevel)
}
// forbidden writes a 403 with a small JSON body.
func forbidden(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden: insufficient scope"}`))
}
// auth wraps a handler with authentication AND authorization. It accepts either
// HTTP Basic (users table bcrypt, with a flag-creds admin fast path that keeps
// inter-node cluster traffic working) or a Bearer API key (sha256-looked-up).
// Identities below minLevel get 403; missing/bad credentials get 401.
//
// The flag-creds fast path is checked BEFORE the users table: inter-node token
// relay and the browser-cached Basic header hit it on every request, and a
// bcrypt verify per hop would be wasteful; the flags are the built-in operator
// and are synced into a system=1 admin row by SyncSystemUser anyway, so this
// shortcut grants no privilege that the flags themselves do not already confer.
func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc {
need := levelRank(minLevel)
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var id identity
switch {
case strings.HasPrefix(r.Header.Get("Authorization"), "Basic "):
u, p, ok := r.BasicAuth()
if !ok {
break
}
if subtle.ConstantTimeCompare([]byte(u), []byte(h.User)) == 1 &&
subtle.ConstantTimeCompare([]byte(p), []byte(h.Password)) == 1 {
id = identity{Type: "service", Name: h.User, Level: "admin"}
} else if usr, ok := h.Store.VerifyUserPassword(u, p); ok {
id = identity{Type: "user", Name: usr.Username, Level: roleLevel(usr.Role), UserID: usr.ID}
_ = h.Store.TouchUserLogin(usr.ID)
}
case strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "):
key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if k, usr, ok := h.Store.LookupApiKey(store.HashApiKey(key)); ok {
id = identity{Type: "key", Name: k.Label, Level: k.Scope, UserID: usr.ID}
_ = h.Store.TouchApiKey(k.ID)
}
}
if id.Level == "" {
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc", Bearer realm="webui4frpc-apikey"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
return
}
if levelRank(id.Level) < need {
forbidden(w)
return
}
ctx := context.WithValue(r.Context(), ctxKey{}, id)
next(w, r.WithContext(ctx))
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

283
internal/httpapi/dist/favicon.svg vendored Normal file
View File

@ -0,0 +1,283 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
<defs>
<!-- 主字母像素块(黑色) -->
<rect id="p" width="12" height="12" fill="#000000" />
<!-- frpc 小像素块(蓝色) -->
<rect id="q" width="5" height="5" fill="#2563eb" />
<!-- 装饰小方块(浅灰) -->
<rect id="d" width="8" height="8" fill="#94a3b8" />
<!-- 小十字星装饰 -->
<rect id="s" width="6" height="6" fill="#94a3b8" />
</defs>
<!-- 圆角白色背景(模拟 App 图标外框) -->
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
<!-- ========== 主字母整体居中(横向间距更合理) ========== -->
<g transform="translate(70, 160)">
<!-- ===== W基于原始结构高度约 150px ===== -->
<g transform="translate(0, 0)">
<!-- 左竖线占2列 -->
<use href="#p" x="0" y="0" />
<use href="#p" x="12" y="0" />
<use href="#p" x="0" y="12" />
<use href="#p" x="12" y="12" />
<use href="#p" x="0" y="24" />
<use href="#p" x="12" y="24" />
<use href="#p" x="0" y="36" />
<use href="#p" x="12" y="36" />
<use href="#p" x="0" y="48" />
<use href="#p" x="12" y="48" />
<use href="#p" x="0" y="60" />
<use href="#p" x="12" y="60" />
<use href="#p" x="0" y="72" />
<use href="#p" x="12" y="72" />
<use href="#p" x="0" y="84" />
<use href="#p" x="12" y="84" />
<use href="#p" x="0" y="96" />
<use href="#p" x="12" y="96" />
<use href="#p" x="0" y="108" />
<use href="#p" x="12" y="108" />
<use href="#p" x="0" y="120" />
<use href="#p" x="12" y="120" />
<use href="#p" x="0" y="132" />
<use href="#p" x="12" y="132" />
<!-- 右竖线占2列 -->
<use href="#p" x="84" y="0" />
<use href="#p" x="96" y="0" />
<use href="#p" x="84" y="12" />
<use href="#p" x="96" y="12" />
<use href="#p" x="84" y="24" />
<use href="#p" x="96" y="24" />
<use href="#p" x="84" y="36" />
<use href="#p" x="96" y="36" />
<use href="#p" x="84" y="48" />
<use href="#p" x="96" y="48" />
<use href="#p" x="84" y="60" />
<use href="#p" x="96" y="60" />
<use href="#p" x="84" y="72" />
<use href="#p" x="96" y="72" />
<use href="#p" x="84" y="84" />
<use href="#p" x="96" y="84" />
<use href="#p" x="84" y="96" />
<use href="#p" x="96" y="96" />
<use href="#p" x="84" y="108" />
<use href="#p" x="96" y="108" />
<use href="#p" x="84" y="120" />
<use href="#p" x="96" y="120" />
<use href="#p" x="84" y="132" />
<use href="#p" x="96" y="132" />
<!-- 中间 V 形(从 y=60 开始) -->
<use href="#p" x="24" y="60" />
<use href="#p" x="48" y="60" />
<use href="#p" x="72" y="60" />
<use href="#p" x="24" y="72" />
<use href="#p" x="48" y="72" />
<use href="#p" x="72" y="72" />
<use href="#p" x="24" y="84" />
<use href="#p" x="48" y="84" />
<use href="#p" x="72" y="84" />
<use href="#p" x="24" y="96" />
<use href="#p" x="48" y="96" />
<use href="#p" x="72" y="96" />
<!-- 底部汇合 -->
<use href="#p" x="36" y="108" />
<use href="#p" x="60" y="108" />
<use href="#p" x="36" y="120" />
<use href="#p" x="60" y="120" />
<use href="#p" x="36" y="132" />
<use href="#p" x="60" y="132" />
<use href="#p" x="48" y="132" />
</g>
<!-- ===== U高度与 W 一致,内部空间增大) ===== -->
<g transform="translate(124, 0)">
<!-- 左竖线占2列 -->
<use href="#p" x="0" y="0" />
<use href="#p" x="12" y="0" />
<use href="#p" x="0" y="12" />
<use href="#p" x="12" y="12" />
<use href="#p" x="0" y="24" />
<use href="#p" x="12" y="24" />
<use href="#p" x="0" y="36" />
<use href="#p" x="12" y="36" />
<use href="#p" x="0" y="48" />
<use href="#p" x="12" y="48" />
<use href="#p" x="0" y="60" />
<use href="#p" x="12" y="60" />
<use href="#p" x="0" y="72" />
<use href="#p" x="12" y="72" />
<use href="#p" x="0" y="84" />
<use href="#p" x="12" y="84" />
<use href="#p" x="0" y="96" />
<use href="#p" x="12" y="96" />
<use href="#p" x="0" y="108" />
<use href="#p" x="12" y="108" />
<use href="#p" x="0" y="120" />
<use href="#p" x="12" y="120" />
<use href="#p" x="0" y="132" />
<use href="#p" x="12" y="132" />
<!-- 右竖线占2列 -->
<use href="#p" x="84" y="0" />
<use href="#p" x="96" y="0" />
<use href="#p" x="84" y="12" />
<use href="#p" x="96" y="12" />
<use href="#p" x="84" y="24" />
<use href="#p" x="96" y="24" />
<use href="#p" x="84" y="36" />
<use href="#p" x="96" y="36" />
<use href="#p" x="84" y="48" />
<use href="#p" x="96" y="48" />
<use href="#p" x="84" y="60" />
<use href="#p" x="96" y="60" />
<use href="#p" x="84" y="72" />
<use href="#p" x="96" y="72" />
<use href="#p" x="84" y="84" />
<use href="#p" x="96" y="84" />
<use href="#p" x="84" y="96" />
<use href="#p" x="96" y="96" />
<use href="#p" x="84" y="108" />
<use href="#p" x="96" y="108" />
<use href="#p" x="84" y="120" />
<use href="#p" x="96" y="120" />
<use href="#p" x="84" y="132" />
<use href="#p" x="96" y="132" />
<!-- 底部横线 -->
<use href="#p" x="0" y="132" />
<use href="#p" x="12" y="132" />
<use href="#p" x="24" y="132" />
<use href="#p" x="36" y="132" />
<use href="#p" x="48" y="132" />
<use href="#p" x="60" y="132" />
<use href="#p" x="72" y="132" />
<use href="#p" x="84" y="132" />
<use href="#p" x="96" y="132" />
<!-- ===== U 内部:竖向排列的 frpc蓝色5×5 像素) ===== -->
<g transform="translate(32, 18)">
<!-- f -->
<use href="#q" x="0" y="0" />
<use href="#q" x="0" y="6" />
<use href="#q" x="0" y="12" />
<use href="#q" x="0" y="18" />
<use href="#q" x="6" y="0" />
<use href="#q" x="12" y="0" />
<use href="#q" x="18" y="0" />
<use href="#q" x="6" y="12" />
<use href="#q" x="12" y="12" />
<!-- r -->
<use href="#q" x="0" y="28" />
<use href="#q" x="0" y="34" />
<use href="#q" x="0" y="40" />
<use href="#q" x="0" y="46" />
<use href="#q" x="6" y="28" />
<use href="#q" x="12" y="28" />
<use href="#q" x="18" y="28" />
<use href="#q" x="6" y="40" />
<use href="#q" x="12" y="40" />
<!-- p -->
<use href="#q" x="0" y="56" />
<use href="#q" x="0" y="62" />
<use href="#q" x="0" y="68" />
<use href="#q" x="0" y="74" />
<use href="#q" x="0" y="80" />
<use href="#q" x="6" y="56" />
<use href="#q" x="12" y="56" />
<use href="#q" x="18" y="56" />
<use href="#q" x="18" y="62" />
<use href="#q" x="18" y="68" />
<use href="#q" x="6" y="74" />
<use href="#q" x="12" y="74" />
<use href="#q" x="18" y="74" />
<!-- c -->
<use href="#q" x="6" y="90" />
<use href="#q" x="12" y="90" />
<use href="#q" x="18" y="90" />
<use href="#q" x="0" y="96" />
<use href="#q" x="0" y="102" />
<use href="#q" x="6" y="108" />
<use href="#q" x="12" y="108" />
<use href="#q" x="18" y="108" />
</g>
</g>
<!-- ===== I中间竖线 2 列宽,高度一致) ===== -->
<g transform="translate(248, 0)">
<!-- 顶部横线占6列 -->
<use href="#p" x="0" y="0" />
<use href="#p" x="12" y="0" />
<use href="#p" x="24" y="0" />
<use href="#p" x="36" y="0" />
<use href="#p" x="48" y="0" />
<use href="#p" x="60" y="0" />
<!-- 中间竖线2 列宽) -->
<use href="#p" x="24" y="12" />
<use href="#p" x="36" y="12" />
<use href="#p" x="24" y="24" />
<use href="#p" x="36" y="24" />
<use href="#p" x="24" y="36" />
<use href="#p" x="36" y="36" />
<use href="#p" x="24" y="48" />
<use href="#p" x="36" y="48" />
<use href="#p" x="24" y="60" />
<use href="#p" x="36" y="60" />
<use href="#p" x="24" y="72" />
<use href="#p" x="36" y="72" />
<use href="#p" x="24" y="84" />
<use href="#p" x="36" y="84" />
<use href="#p" x="24" y="96" />
<use href="#p" x="36" y="96" />
<use href="#p" x="24" y="108" />
<use href="#p" x="36" y="108" />
<use href="#p" x="24" y="120" />
<use href="#p" x="36" y="120" />
<!-- 底部横线占6列 -->
<use href="#p" x="0" y="132" />
<use href="#p" x="12" y="132" />
<use href="#p" x="24" y="132" />
<use href="#p" x="36" y="132" />
<use href="#p" x="48" y="132" />
<use href="#p" x="60" y="132" />
</g>
</g>
<!-- ========== 外围装饰元素(像素风,增加层次) ========== -->
<!-- 左上角小十字星 -->
<g transform="translate(40, 40)">
<use href="#s" x="0" y="0" />
<use href="#s" x="0" y="12" />
<use href="#s" x="6" y="6" />
<use href="#s" x="12" y="0" />
<use href="#s" x="12" y="12" />
</g>
<!-- 右上角小方块 -->
<use href="#d" x="440" y="40" />
<!-- 左下角小方块 -->
<use href="#d" x="40" y="440" />
<!-- 右下角小十字星 -->
<g transform="translate(436, 436)">
<use href="#s" x="0" y="0" />
<use href="#s" x="0" y="12" />
<use href="#s" x="6" y="6" />
<use href="#s" x="12" y="0" />
<use href="#s" x="12" y="12" />
</g>
<!-- 三个随机黑色装饰点更大一点12×12 -->
<rect x="90" y="380" width="12" height="12" fill="#000000" />
<rect x="420" y="140" width="12" height="12" fill="#000000" />
<rect x="240" y="60" width="12" height="12" fill="#000000" />
<!-- 额外小点缀:蓝色小点,呼应 frpc 颜色 -->
<rect x="180" y="400" width="6" height="6" fill="#2563eb" />
<rect x="370" y="80" width="6" height="6" fill="#2563eb" />
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -3,9 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
<script type="module" crossorigin src="/assets/index-CXBf2fLP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_Hvtf6C.css">
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title>
<script type="module" crossorigin src="/assets/index-C5WLVFP2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CL42Ur3_.css">
</head>
<body>
<div id="app"></div>

View File

@ -29,6 +29,11 @@ func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
h.handleCanvasGet(w, r)
case http.MethodPut:
// Route is registered at read so viewer GETs work; PUT needs write.
if !hasLevel(r, "write") {
forbidden(w)
return
}
h.saveCanvas(w, r)
default:
methodNotAllowed(w)
@ -41,6 +46,19 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if !h.applyCanvas(w, r, &canvas) {
return
}
h.handleCanvasGet(w, r)
}
// applyCanvas performs the full-replace semantics shared by PUT /canvas and
// POST /canvas/import: loopback rewrite, upsert locals + delete-missing (with
// localOnly stop / cluster revoke), upsert remotes + delete-missing, wholesale
// link replace, cluster task submit for non-localOnly forwards, and a
// SyncWorkers pass. It writes errors to w and returns false on failure so the
// caller knows not to write a success response.
func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *canvasData) bool {
s := h.Store
// Rewrite loopback backend addresses for cluster-distributed forwards.
@ -57,7 +75,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
for _, l := range canvas.Locals {
if err := s.UpsertLocal(l); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
return false
}
}
// Delete locals not present. A removed localOnly forward is cancelled
@ -101,7 +119,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
for _, rem := range canvas.Remotes {
if err := s.UpsertRemote(rem); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
return false
}
}
if existing, err := s.ListRemotes(); err == nil {
@ -119,32 +137,44 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
// Replace links wholesale.
if err := s.ReplaceLinks(canvas.Links); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
return false
}
// Cluster distribution: non-localOnly forwards are submitted as token-ring
// tasks (claimed by the lowest-load member). Local-only forwards are NOT
// submitted — they stay on this node and are only visible here.
// Cluster distribution: reconcile non-localOnly forwards against the ring
// topology, respecting each link's Disabled flag. A non-disabled forward
// not yet in topology is submitted (lowest-load member claims it); a
// disabled forward present in topology is revoked. plan §画布差异判断,
// extended so a per-forward stop made on the forwards page (disabled=true)
// is not re-activated by a later canvas save. Local-only forwards are NOT
// submitted — they stay on this node.
localByName := map[string]store.Local{}
for _, l := range canvas.Locals {
localByName[l.Name] = l
}
remoteByName := map[string]store.Remote{}
for _, r := range canvas.Remotes {
remoteByName[r.Name] = r
}
if h.Ring != nil {
for _, l := range canvas.Locals {
if l.LocalOnly {
for _, ln := range canvas.Links {
loc, ok := localByName[ln.Local]
if !ok || loc.LocalOnly {
continue
}
for _, ln := range canvas.Links {
if ln.Local != l.Name {
continue
}
var rem store.Remote
for _, rr := range canvas.Remotes {
if rr.Name == ln.Remote {
rem = rr
break
}
}
if rem.Name != "" {
h.Ring.SubmitTask(l, rem, ln)
}
rem, ok := remoteByName[ln.Remote]
if !ok {
continue
}
if ln.Disabled {
// Stopped on the forwards page: make sure it leaves the topology.
if h.Ring.HasTask(ln.Local, ln.Remote, ln.RemotePort) {
h.Ring.RevokeTask(loc, rem, ln)
}
continue
}
// SubmitTask is idempotent (HasTask guard), so re-saving an active
// canvas is a no-op for forwards already in the topology.
h.Ring.SubmitTask(loc, rem, ln)
}
}
@ -153,8 +183,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
if h.SyncWorkers != nil {
h.SyncWorkers()
}
h.handleCanvasGet(w, r)
return true
}
// ---- Settings ----
@ -280,6 +309,12 @@ func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
case "start", "stop", "restart":
switch r.Method {
case http.MethodPost:
// Route registered at read so status/config/logs GETs work; worker
// lifecycle mutations need write.
if !hasLevel(r, "write") {
forbidden(w)
return
}
var err error
if action == "start" {
err = h.Process.Start(name)

View File

@ -0,0 +1,257 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"webui4frpc/internal/cluster"
"webui4frpc/internal/store"
)
// ---- Locals: single-resource CRUD ----
// handleLocals operates on /api/manager/locals: PUT upserts a single local.
// Mirrors applyCanvas's per-local logic (loopback rewrite + ring task submit
// for the local's links when it is a cluster forward, or a SyncWorkers pass
// for a local-only forward).
func (h *Handler) handleLocals(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
methodNotAllowed(w)
return
}
var l store.Local
if err := json.NewDecoder(r.Body).Decode(&l); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if l.Name == "" {
http.Error(w, "local name required", http.StatusBadRequest)
return
}
// Loopback rewrite, same as applyCanvas: a non-localOnly forward must be
// reachable from whichever cluster node claims it.
if !l.LocalOnly && cluster.IsLoopbackIP(l.IP) {
l.IP = cluster.RewriteForCluster(l.IP)
}
if err := h.Store.UpsertLocal(l); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Submit ring tasks for this local's existing links when it is a cluster
// forward; local-only forwards just refresh their local worker.
if !l.LocalOnly && h.Ring != nil {
fwd, _ := h.Store.LinksForLocal(l.Name)
for _, ln := range fwd {
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
h.Ring.SubmitTask(l, rem, store.Link{
Local: l.Name, Remote: rem.Name, RemotePort: ln.RemotePort,
})
}
}
}
if h.SyncWorkers != nil {
h.SyncWorkers()
}
writeJSON(w, http.StatusOK, l)
}
// handleLocalByName operates on /api/manager/locals/{name}: DELETE removes a
// single local, mirroring applyCanvas's removed-local branch.
func (h *Handler) handleLocalByName(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/locals/")
if name == "" {
http.Error(w, "local name required", http.StatusBadRequest)
return
}
l, ok := h.Store.GetLocal(name)
if !ok {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
if l.LocalOnly {
if h.Process != nil {
if fwd, _ := h.Store.LinksForLocal(name); len(fwd) > 0 {
_ = h.Process.Stop(fwd[0].Remote)
}
}
} else if h.Ring != nil {
fwd, _ := h.Store.LinksForLocal(name)
for _, ln := range fwd {
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
h.Ring.RevokeTask(l, rem, store.Link{
Local: name, Remote: rem.Name, RemotePort: ln.RemotePort,
})
}
}
}
_ = h.Store.DeleteLocal(name)
w.WriteHeader(http.StatusNoContent)
default:
methodNotAllowed(w)
}
}
// ---- Links: single-resource CRUD ----
// handleLinks is the collection endpoint: POST adds a single link.
// For a cluster (non-localOnly) forward it submits a ring task so the owning
// node spawns the worker; for a local-only forward it just SyncWorkers so the
// local frpc picks up the new proxy.
func (h *Handler) handleLinks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var ln store.Link
if err := json.NewDecoder(r.Body).Decode(&ln); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if ln.Local == "" || ln.Remote == "" || ln.RemotePort <= 0 {
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
return
}
if _, ok := h.Store.GetLocal(ln.Local); !ok {
http.Error(w, "local not found", http.StatusBadRequest)
return
}
if _, ok := h.Store.GetRemote(ln.Remote); !ok {
http.Error(w, "remote not found", http.StatusBadRequest)
return
}
// Idempotent: an identical link already present is returned as-is.
for _, existing := range mustListLinks(h.Store) {
if existing.Local == ln.Local && existing.Remote == ln.Remote && existing.RemotePort == ln.RemotePort {
writeJSON(w, http.StatusOK, existing)
return
}
}
created, err := h.Store.AddLink(ln)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if loc, ok := h.Store.GetLocal(ln.Local); ok {
if !loc.LocalOnly && h.Ring != nil {
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
h.Ring.SubmitTask(loc, rem, created)
}
}
}
if h.SyncWorkers != nil {
h.SyncWorkers()
}
writeJSON(w, http.StatusCreated, created)
}
// handleLinkByID operates on /api/manager/links/{id}: DELETE removes a single
// link, revoking the cluster forward on its owning node when applicable.
func (h *Handler) handleLinkByID(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.URL.Path, apiPrefix+"/links/")
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid link id", http.StatusBadRequest)
return
}
ln, ok := h.Store.GetLink(id)
if !ok {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
if loc, ok := h.Store.GetLocal(ln.Local); ok {
if !loc.LocalOnly && h.Ring != nil {
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
h.Ring.RevokeTask(loc, rem, ln)
}
}
}
_ = h.Store.DeleteLink(id)
if h.SyncWorkers != nil {
h.SyncWorkers()
}
w.WriteHeader(http.StatusNoContent)
default:
methodNotAllowed(w)
}
}
func mustListLinks(s *store.Store) []store.Link {
links, _ := s.ListLinks()
return links
}
// ---- Canvas export / import ----
// canvasExportEnvelope wraps a canvas with provenance metadata for backup files.
type canvasExportEnvelope struct {
Type string `json:"_type"`
Version int `json:"version"`
ExportedAt int64 `json:"exportedAt"`
Exporter string `json:"exporter"`
Canvas canvasData `json:"canvas"`
}
// handleCanvasExport returns the full canvas wrapped in a metadata envelope and
// as a downloadable attachment. Read-level (auditors can export).
func (h *Handler) handleCanvasExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
locals, _ := h.Store.ListLocals()
remotes, _ := h.Store.ListRemotes()
links, _ := h.Store.ListLinks()
env := canvasExportEnvelope{
Type: "webui4frpc-canvas",
Version: 1,
ExportedAt: time.Now().Unix(),
Exporter: h.SelfAddr,
Canvas: canvasData{Locals: locals, Remotes: remotes, Links: links},
}
body, err := json.MarshalIndent(env, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="webui4frpc-canvas.json"`)
_, _ = w.Write(body)
}
// handleCanvasImport restores a previously exported canvas. Accepts either the
// full envelope ({"_type":"webui4frpc-canvas","canvas":{...}}) or a bare canvas
// ({locals,remotes,links}); both are applied via applyCanvas (full replace).
func (h *Handler) handleCanvasImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
return
}
// Try the envelope first; fall back to a bare canvas ({locals,remotes,
// links}). Probing for the envelope keeps both shapes working so a caller
// can replay either a /canvas/export bundle or a raw PUT /canvas body.
var env canvasExportEnvelope
var canvas canvasData
if jerr := json.Unmarshal(raw, &env); jerr == nil && env.Type == "webui4frpc-canvas" {
canvas = env.Canvas
} else if jerr := json.Unmarshal(raw, &canvas); jerr != nil {
http.Error(w, "parse json: "+jerr.Error(), http.StatusBadRequest)
return
}
if !h.applyCanvas(w, r, &canvas) {
return
}
h.handleCanvasGet(w, r)
}

View File

@ -0,0 +1,278 @@
package httpapi
import (
"encoding/json"
"net/http"
"webui4frpc/internal/store"
)
// forwardsReq is the {local,remote,remotePort} natural key identifying a single
// forward. The triple is stable across canvas re-saves (unlike Link.ID, which
// ReplaceLinks wholesale-replaces) and matches HasTask's keying.
type forwardsReq struct {
Local string `json:"local"`
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
}
// groupReq selects a group for one-click start/stop on the forwards page.
type groupReq struct {
Group string `json:"group"`
}
// findLinkByTriple returns the link matching the (local,remote,remotePort)
// natural key, or ok=false.
func findLinkByTriple(s *store.Store, local, remote string, port int) (store.Link, bool) {
links, err := s.ListLinks()
if err != nil {
return store.Link{}, false
}
for _, l := range links {
if l.Local == local && l.Remote == remote && l.RemotePort == port {
return l, true
}
}
return store.Link{}, false
}
// startForward flips a forward to enabled and brings it up. A local-only
// forward restarts/starts its local frpc worker so the re-enabled proxy is
// rendered back in; a cluster (non-localOnly) forward is submitted to the ring
// so the lowest-load member claims and spawns it (plan §令牌环协议). SubmitTask
// is idempotent via HasTask. Returns store.ErrNotFound if the forward is gone.
func (h *Handler) startForward(local, remote string, port int) error {
ln, ok := findLinkByTriple(h.Store, local, remote, port)
if !ok {
return store.ErrNotFound
}
loc, ok := h.Store.GetLocal(local)
if !ok {
return store.ErrNotFound
}
rem, ok := h.Store.GetRemote(remote)
if !ok {
return store.ErrNotFound
}
_ = h.Store.SetLinkDisabled(local, remote, port, false)
// Reflect the post-start (enabled) state in the snapshot handed to the
// ring: ln was fetched before SetLinkDisabled, so for a re-start of a
// previously stopped forward it still carries disabled=true. Without this
// the topology entry would embed a stale disabled flag (cosmetically
// wrong, and confusing if anything reads topology's link snapshot).
ln.Disabled = false
if loc.LocalOnly {
if h.Process != nil {
// Re-render (proxy re-added) on a running worker, or start it.
if _, has := h.Process.Status(remote); has {
_ = h.Process.Restart(remote)
} else {
_ = h.Process.Start(remote)
}
}
return nil
}
if h.Ring != nil {
h.Ring.SubmitTask(loc, rem, ln)
}
return nil
}
// stopForward flips a forward to disabled and tears it down. A local-only
// forward restarts its worker so renderRemote omits the proxy (sibling forwards
// on the same remote keep running — true per-forward stop); a cluster forward
// is revoked so the owning node cancels its worker and drops it from the
// topology (plan §任务撤销). RevokeTask is idempotent.
func (h *Handler) stopForward(local, remote string, port int) error {
ln, ok := findLinkByTriple(h.Store, local, remote, port)
if !ok {
return store.ErrNotFound
}
loc, ok := h.Store.GetLocal(local)
if !ok {
return store.ErrNotFound
}
rem, ok := h.Store.GetRemote(remote)
if !ok {
return store.ErrNotFound
}
_ = h.Store.SetLinkDisabled(local, remote, port, true)
if loc.LocalOnly {
// Re-render without this proxy; only meaningful while a worker runs.
if h.Process != nil {
if _, has := h.Process.Status(remote); has {
_ = h.Process.Restart(remote)
}
}
return nil
}
if h.Ring != nil {
h.Ring.RevokeTask(loc, rem, ln)
}
return nil
}
// handleForwardsStart toggles one forward on.
func (h *Handler) handleForwardsStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var req forwardsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
return
}
if err := h.startForward(req.Local, req.Remote, req.RemotePort); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleForwardsStop toggles one forward off.
func (h *Handler) handleForwardsStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var req forwardsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
return
}
if err := h.stopForward(req.Local, req.Remote, req.RemotePort); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleForwardsGroupStart starts every forward in a group with one click.
func (h *Handler) handleForwardsGroupStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var req groupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
links, err := h.Store.ListLinks()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, ln := range links {
if ln.Group != req.Group {
continue
}
_ = h.startForward(ln.Local, ln.Remote, ln.RemotePort)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleForwardsGroupStop stops every forward in a group with one click.
func (h *Handler) handleForwardsGroupStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var req groupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
links, err := h.Store.ListLinks()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, ln := range links {
if ln.Group != req.Group {
continue
}
_ = h.stopForward(ln.Local, ln.Remote, ln.RemotePort)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// assignReq carries the group label to assign to a single forward (status
// page group chip edit). Empty Group clears the assignment (移出分组).
type assignReq struct {
Local string `json:"local"`
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
Group string `json:"group"`
}
// handleForwardsAssign changes the group label of a single forward. The
// status page group chip is the quick entry; the canvas port editor also
// carries a group field (both persist via the same store column). The
// change is purely a DB update — no worker/ring action is needed (group is
// a management label, not a runtime knob).
func (h *Handler) handleForwardsAssign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var req assignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
return
}
if _, ok := findLinkByTriple(h.Store, req.Local, req.Remote, req.RemotePort); !ok {
http.Error(w, store.ErrNotFound.Error(), http.StatusNotFound)
return
}
if err := h.Store.SetLinkGroup(req.Local, req.Remote, req.RemotePort, req.Group); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleForwardsGroupDelete dissolves a group: every forward in the named
// group is moved to 未分组 (grp=""). The group itself is not a stored entity
// — it exists only as a label on links — so clearing all members is the
// complete "delete". No worker/ring action (group is a management label).
func (h *Handler) handleForwardsGroupDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var req groupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if req.Group == "" {
http.Error(w, "group required", http.StatusBadRequest)
return
}
links, err := h.Store.ListLinks()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, ln := range links {
if ln.Group != req.Group {
continue
}
_ = h.Store.SetLinkGroup(ln.Local, ln.Remote, ln.RemotePort, "")
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

View File

@ -0,0 +1,146 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"sync"
"time"
)
// workerLog is one frpc worker's log tail on a node.
type workerLog struct {
Name string `json:"name"`
Remote string `json:"remote"` // worker key = remote name
State string `json:"state"`
Lines string `json:"lines"`
}
// nodeLogsResp is the body of GET /node/logs: all frpc workers handling
// forwards on THIS node (cluster-owned workers that run here + local-only ones).
type nodeLogsResp struct {
Node string `json:"node"`
Workers []workerLog `json:"workers"`
}
// localWorkerLogs gathers this node's frpc worker logs. Shared by /node/logs
// and the /cluster/logs/export self entry so the requesting node doesn't HTTP
// back to itself.
func (h *Handler) localWorkerLogs() nodeLogsResp {
out := nodeLogsResp{Node: h.SelfAddr, Workers: []workerLog{}}
if h.Process == nil {
return out
}
for _, name := range h.Process.ListWorkers() {
st, _ := h.Process.Status(name)
lines, _ := tailFile(h.Process.LogPath(name), 64*1024)
out.Workers = append(out.Workers, workerLog{
Name: name, Remote: name, State: st.State, Lines: lines,
})
}
return out
}
// handleNodeLogs returns the frpc worker logs of THIS node only. Read-level:
// auditors can pull it directly on any node, and the cluster export fans it out.
func (h *Handler) handleNodeLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
writeJSON(w, http.StatusOK, h.localWorkerLogs())
}
// clusterWorkerLogNode is one node's entry in the export bundle.
type clusterWorkerLogNode struct {
ID string `json:"id"`
Addr string `json:"addr"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Logs *nodeLogsResp `json:"logs,omitempty"`
}
// handleClusterLogsExport fans out GET /node/logs to every alive ring node
// (using the flag Basic creds, which resolve to admin on each peer via the
// flag fast path) and aggregates the results into a downloadable bundle.
// Unreachable nodes are marked error but don't abort the export. The
// requester's own logs are gathered locally without an HTTP round-trip.
func (h *Handler) handleClusterLogsExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
snap := h.Ring.Snapshot()
selfID := snap.SelfID
type target struct{ id, addr string }
var targets []target
for _, n := range snap.Nodes {
if !n.Alive || n.Addr == "" {
continue
}
targets = append(targets, target{n.ID, n.Addr})
}
results := make([]clusterWorkerLogNode, len(targets))
var wg sync.WaitGroup
cli := &http.Client{Timeout: 8 * time.Second}
for i, t := range targets {
wg.Add(1)
go func(i int, t target) {
defer wg.Done()
entry := clusterWorkerLogNode{ID: t.id, Addr: t.addr}
// Self: gather locally, no HTTP round-trip.
if t.id == selfID {
logs := h.localWorkerLogs()
entry.OK = true
entry.Logs = &logs
results[i] = entry
return
}
req, err := http.NewRequest(http.MethodGet, "http://"+t.addr+apiPrefix+"/node/logs", nil)
if err != nil {
entry.Error = err.Error()
results[i] = entry
return
}
req.SetBasicAuth(h.User, h.Password)
resp, err := cli.Do(req)
if err != nil {
entry.Error = "unreachable: " + err.Error()
results[i] = entry
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode != http.StatusOK {
entry.Error = "HTTP " + resp.Status
results[i] = entry
return
}
var logs nodeLogsResp
if err := json.Unmarshal(body, &logs); err != nil {
entry.Error = "parse: " + err.Error()
results[i] = entry
return
}
entry.OK = true
entry.Logs = &logs
results[i] = entry
}(i, t)
}
wg.Wait()
bundle := map[string]any{
"_type": "webui4frpc-worker-logs",
"version": 1,
"exportedAt": time.Now().Unix(),
"requester": selfID,
"nodes": results,
}
body, _ := json.MarshalIndent(bundle, "", " ")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="worker-logs.json"`)
_, _ = w.Write(body)
}

View File

@ -0,0 +1,206 @@
package httpapi
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"webui4frpc/internal/store"
)
// handleMe returns the authenticated principal. The frontend uses this to gate
// UI (hide Users nav + disable write controls for viewer/audit users).
func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
id := identityFrom(r)
writeJSON(w, http.StatusOK, map[string]any{
"type": id.Type,
"name": id.Name,
"level": id.Level,
"userId": id.UserID,
})
}
// ---- Users ----
// handleUsers is the collection endpoint: GET lists users, POST creates one.
// Admin only. password_hash is never serialized (User.PasswordHash has json:"-").
func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
users, err := h.Store.ListUsers()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
case http.MethodPost:
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
u, err := h.Store.CreateUser(req.Username, req.Password, req.Role)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, u)
default:
methodNotAllowed(w)
}
}
// handleUserByName operates on /users/{name}: PUT updates role/enabled/password,
// DELETE removes the user. System (flag-synced) users are read-only; the last
// enabled admin cannot be deleted or disabled.
func (h *Handler) handleUserByName(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/users/")
if name == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
u, ok := h.Store.GetUser(name)
if !ok {
http.Error(w, "user not found", http.StatusNotFound)
return
}
switch r.Method {
case http.MethodPut:
var req struct {
Password string `json:"password"`
Role string `json:"role"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
// Role defaults to the current value when omitted.
role := req.Role
if role == "" {
role = u.Role
}
enabled := u.Enabled
if req.Enabled != nil {
enabled = *req.Enabled
}
// Guard: never disable/demote the last admin.
if u.Role == "admin" && (role != "admin" || !enabled) {
n, _ := h.Store.CountAdmins()
if n <= 1 {
http.Error(w, "cannot demote or disable the last admin", http.StatusConflict)
return
}
}
if err := h.Store.UpdateUser(u.ID, role, enabled, req.Password); err != nil {
writeStoreErr(w, err)
return
}
updated, _ := h.Store.GetUserByID(u.ID)
writeJSON(w, http.StatusOK, updated)
case http.MethodDelete:
if u.System {
http.Error(w, "system user is managed by -user/-password flags", http.StatusConflict)
return
}
if u.Role == "admin" {
n, _ := h.Store.CountAdmins()
if n <= 1 {
http.Error(w, "cannot delete the last admin", http.StatusConflict)
return
}
}
if err := h.Store.DeleteUser(u.ID); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
default:
methodNotAllowed(w)
}
}
// ---- API keys ----
// handleApiKeys is the collection endpoint: GET lists keys (no hashes), POST
// creates a key and returns the plaintext exactly once.
func (h *Handler) handleApiKeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
keys, err := h.Store.ListApiKeys()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"apiKeys": keys})
case http.MethodPost:
var req struct {
UserID int64 `json:"userId"`
Label string `json:"label"`
Scope string `json:"scope"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
k, plaintext, err := h.Store.CreateApiKey(req.UserID, req.Label, req.Scope)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": k.ID,
"userId": k.UserID,
"label": k.Label,
"scope": k.Scope,
"prefix": k.Prefix,
"createdAt": k.CreatedAt,
"key": plaintext, // shown exactly once
})
default:
methodNotAllowed(w)
}
}
// handleApiKeyByID operates on /apikeys/{id}: DELETE revokes the key.
func (h *Handler) handleApiKeyByID(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.URL.Path, apiPrefix+"/apikeys/")
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid key id", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodDelete:
if err := h.Store.DeleteApiKey(id); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
default:
methodNotAllowed(w)
}
}
// writeStoreErr maps store sentinel errors to HTTP statuses.
func writeStoreErr(w http.ResponseWriter, err error) {
switch err {
case store.ErrNotFound:
http.Error(w, "not found", http.StatusNotFound)
case store.ErrAlreadyExists:
http.Error(w, "already exists", http.StatusConflict)
case store.ErrInvalid:
http.Error(w, "invalid argument", http.StatusBadRequest)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

View File

@ -57,68 +57,88 @@ const (
func NewServeMux(h *Handler) (http.Handler, error) {
mux := http.NewServeMux()
auth := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != h.User || p != h.Password {
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
next(w, r)
}
}
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
// API routes (basic auth).
mux.HandleFunc(apiPrefix+"/status", auth(h.handleStatus))
mux.HandleFunc(apiPrefix+"/canvas", auth(h.handleCanvasSave))
mux.HandleFunc(apiPrefix+"/settings", auth(func(w http.ResponseWriter, r *http.Request) {
// read tier (viewer + read-scope key + everyone above): pure-GET reads.
// Routes that also accept a mutating method re-check the write level inside
// the handler so a viewer GET still works while viewer PUT/POST is 403.
mux.HandleFunc(apiPrefix+"/status", h.auth("read")(h.handleStatus))
mux.HandleFunc(apiPrefix+"/canvas", h.auth("read")(h.handleCanvasSave)) // GET read; PUT write-checked inside
mux.HandleFunc(apiPrefix+"/canvas/export", h.auth("read")(h.handleCanvasExport))
mux.HandleFunc(apiPrefix+"/canvas/import", h.auth("write")(h.handleCanvasImport))
mux.HandleFunc(apiPrefix+"/locals", h.auth("write")(h.handleLocals))
mux.HandleFunc(apiPrefix+"/locals/", h.auth("write")(h.handleLocalByName))
mux.HandleFunc(apiPrefix+"/links", h.auth("write")(h.handleLinks))
mux.HandleFunc(apiPrefix+"/links/", h.auth("write")(h.handleLinkByID))
// Per-forward start/stop + one-click group start/stop. Decoupled from the
// remote-node worker controls so the forwards page owns forward lifecycle
// (local-only → local worker restart; cluster → ring submit/revoke).
mux.HandleFunc(apiPrefix+"/forwards/start", h.auth("write")(h.handleForwardsStart))
mux.HandleFunc(apiPrefix+"/forwards/stop", h.auth("write")(h.handleForwardsStop))
mux.HandleFunc(apiPrefix+"/forwards/group/start", h.auth("write")(h.handleForwardsGroupStart))
mux.HandleFunc(apiPrefix+"/forwards/group/stop", h.auth("write")(h.handleForwardsGroupStop))
mux.HandleFunc(apiPrefix+"/forwards/assign", h.auth("write")(h.handleForwardsAssign))
mux.HandleFunc(apiPrefix+"/forwards/group/delete", h.auth("write")(h.handleForwardsGroupDelete))
mux.HandleFunc(apiPrefix+"/settings", h.auth("read")(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && !hasLevel(r, "write") {
forbidden(w)
return
}
if r.Method == http.MethodPut {
h.handleSettingsPut(w, r)
return
}
h.handleSettingsGet(w, r)
}))
mux.HandleFunc(apiPrefix+"/binary/status", auth(h.handleBinaryStatus))
mux.HandleFunc(apiPrefix+"/binary/install", auth(h.handleBinaryInstall))
mux.HandleFunc(apiPrefix+"/binary/status", h.auth("read")(h.handleBinaryStatus))
mux.HandleFunc(apiPrefix+"/binary/install", h.auth("write")(h.handleBinaryInstall))
// Profile lifecycle routes.
mux.HandleFunc(apiPrefix+"/profiles/", auth(h.handleProfile))
mux.HandleFunc(apiPrefix+"/remotes", auth(h.handleRemoteUpsert))
mux.HandleFunc(apiPrefix+"/remotes/", auth(h.handleRemoteDelete))
// Profile lifecycle: status/config/logs = read; start/stop/restart = write
// (write-checked inside handleProfile).
mux.HandleFunc(apiPrefix+"/profiles/", h.auth("read")(h.handleProfile))
// M6: cluster nodes + per-node cached versions (UI + discovery).
mux.HandleFunc(apiPrefix+"/cluster/nodes", auth(h.handleClusterNodes))
mux.HandleFunc(apiPrefix+"/cluster/cache", auth(h.handleClusterCache))
mux.HandleFunc(apiPrefix+"/cluster/token", auth(h.handleClusterToken))
mux.HandleFunc(apiPrefix+"/cluster/ring", auth(h.handleClusterRing))
mux.HandleFunc(apiPrefix+"/cluster/join", auth(h.handleClusterJoin))
mux.HandleFunc(apiPrefix+"/cluster/task", auth(h.handleClusterTask))
mux.HandleFunc(apiPrefix+"/cluster/node-remove", auth(h.handleNodeRemove))
mux.HandleFunc(apiPrefix+"/cluster/create", auth(h.handleClusterCreate))
mux.HandleFunc(apiPrefix+"/cluster/join-ring", auth(h.handleClusterJoinRing))
// write tier (write-scope key / admin): single-resource mutation.
mux.HandleFunc(apiPrefix+"/remotes", h.auth("write")(h.handleRemoteUpsert))
mux.HandleFunc(apiPrefix+"/remotes/", h.auth("write")(h.handleRemoteDelete))
// M6 cluster: reads + control plane. Inter-node token relay uses Basic flag
// creds which resolve to admin via the flag fast path, so write level keeps
// the ring working while blocking read-scope keys from injecting tasks.
mux.HandleFunc(apiPrefix+"/cluster/nodes", h.auth("read")(h.handleClusterNodes))
mux.HandleFunc(apiPrefix+"/cluster/cache", h.auth("read")(h.handleClusterCache)) // GET read; POST write-checked inside
mux.HandleFunc(apiPrefix+"/cluster/ring", h.auth("read")(h.handleClusterRing))
mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs))
mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport))
mux.HandleFunc(apiPrefix+"/cluster/token", h.auth("write")(h.handleClusterToken))
mux.HandleFunc(apiPrefix+"/cluster/join", h.auth("write")(h.handleClusterJoin))
mux.HandleFunc(apiPrefix+"/cluster/task", h.auth("write")(h.handleClusterTask))
mux.HandleFunc(apiPrefix+"/cluster/node-remove", h.auth("write")(h.handleNodeRemove))
mux.HandleFunc(apiPrefix+"/cluster/create", h.auth("write")(h.handleClusterCreate))
mux.HandleFunc(apiPrefix+"/cluster/join-ring", h.auth("write")(h.handleClusterJoinRing))
// Account & API key management (admin only).
mux.HandleFunc(apiPrefix+"/me", h.auth("read")(h.handleMe))
mux.HandleFunc(apiPrefix+"/users", h.auth("admin")(h.handleUsers))
mux.HandleFunc(apiPrefix+"/users/", h.auth("admin")(h.handleUserByName))
mux.HandleFunc(apiPrefix+"/apikeys", h.auth("admin")(h.handleApiKeys))
mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("admin")(h.handleApiKeyByID))
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
// Not under /api so peers hit it directly; auth still applied.
mux.HandleFunc("/frpc/", auth(h.handleFrpcBinary))
// Not under /api so peers hit it directly; auth still applied. Peers
// resolve to admin via the flag fast path.
mux.HandleFunc("/frpc/", h.auth("read")(h.handleFrpcBinary))
// Static assets (also basic auth) under /.
// The SPA shell is served behind the SAME Basic Auth as the API (plan:
// "healthz 免认证,其余 Basic Auth"). Without this, the browser loads the
// page without a 401 challenge, never caches credentials, and every
// same-origin /api/* fetch (credentials:"same-origin") gets 401 — so the
// SPA renders but all data pages read as empty ("集群未启动"). Wrapping /
// in auth makes the browser prompt once, cache the Basic header for the
// origin, and send it on every subsequent asset + API request.
mux.HandleFunc("/", auth(h.handleStatic))
// Static assets behind the same auth as the API: the browser caches the
// Basic header once and sends it on every asset + /api/* request, so the
// SPA loads for any valid identity (viewer included).
mux.HandleFunc("/", h.auth("read")(h.handleStatic))
return mux, nil
}
// handleStatic serves the embedded web build. Paths map to dist files; / and
// unknown paths serve index.html for SPA routing. The SPA uses hash-based
// routing, so returning index.html for "/" is enough — a redirect here would
@ -207,6 +227,28 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
GroupCount int `json:"groupCount,omitempty"`
}
// Build per-forward lookup tables: a localByName index for the forwards
// array, and an ownerOf map from ring topology (keyed by the
// local/remote/remotePort triple) so each forward card can show its owning
// node and active state. plan §任务撤销 / §令牌环协议.
localByName := make(map[string]store.Local, len(locals))
for _, l := range locals {
localByName[l.Name] = l
}
type topoKey struct{ local, remote string; port int }
ownerOf := map[topoKey]string{}
selfID := h.SelfAddr
if h.Ring != nil {
snap := h.Ring.Snapshot()
selfID = snap.SelfID
for _, e := range snap.Topology {
k := topoKey{e.Local.Name, e.Remote.Name, e.Link.RemotePort}
if _, ok := ownerOf[k]; !ok {
ownerOf[k] = e.OwnerID
}
}
}
profiles := make([]profileStatus, 0, len(remotes))
binary := ""
if h.BinaryPath != nil {
@ -236,7 +278,8 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
}
// Local status: each local plus its forwarding targets and whether each
// target's worker is healthy (running).
// target's worker is healthy (running). The per-forward owner/active detail
// lives in the forwards array below; this is the local-services overview.
type localTargetStatus struct {
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
@ -265,6 +308,51 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
localStatuses = append(localStatuses, localStatus{Local: l, Targets: ts})
}
// Forwards: one entry per link — the forward-centric view the UI renders as
// cards. kind distinguishes 本地转发 (localOnly) from 远程转发 (cluster-
// distributed); ownerId/active are resolved from ring topology (remote) or
// the local worker (localOnly); group drives one-click group start/stop.
type forwardStatus struct {
Local string `json:"local"`
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
LocalOnly bool `json:"localOnly"`
Kind string `json:"kind"` // "local" | "remote"
OwnerID string `json:"ownerId,omitempty"`
Active bool `json:"active"`
Disabled bool `json:"disabled"`
Group string `json:"group,omitempty"`
LocalIP string `json:"localIp,omitempty"`
LocalPort int `json:"localPort,omitempty"`
LocalProto string `json:"localProto,omitempty"`
}
links, _ := h.Store.ListLinks()
forwards := make([]forwardStatus, 0, len(links))
for _, ln := range links {
loc, ok := localByName[ln.Local]
if !ok {
continue
}
fs := forwardStatus{
Local: ln.Local, Remote: ln.Remote, RemotePort: ln.RemotePort,
LocalOnly: loc.LocalOnly, Disabled: ln.Disabled, Group: ln.Group,
LocalIP: loc.IP, LocalPort: loc.Port, LocalProto: loc.Protocol,
}
if loc.LocalOnly {
fs.Kind = "local"
fs.OwnerID = selfID
if st, has := h.Process.Status(ln.Remote); has {
fs.Active = !ln.Disabled && st.State == "running"
}
} else {
fs.Kind = "remote"
owner, inTopo := ownerOf[topoKey{ln.Local, ln.Remote, ln.RemotePort}]
fs.OwnerID = owner
fs.Active = !ln.Disabled && inTopo
}
forwards = append(forwards, fs)
}
resp := map[string]any{
"version": "0.1.0",
"workDir": h.WorkDir,
@ -274,6 +362,8 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
"binaryPath": binary,
"profiles": profiles,
"localStatus": localStatuses,
"forwards": forwards,
"selfId": selfID,
}
writeJSON(w, http.StatusOK, resp)
}
@ -349,6 +439,11 @@ func (h *Handler) handleClusterCache(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"cache": h.Cluster.CacheInfo()})
case http.MethodPost:
// Route registered at read so GET works; cache pruning needs write.
if !hasLevel(r, "write") {
forbidden(w)
return
}
var req struct {
Keep int `json:"keep"`
}
@ -379,13 +474,27 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
return
}
// Heartbeat ping: empty body (no token) is a liveness check from the
// leader's predecessor — answer 200 without processing.
// leader's predecessor (the FALLBACK leader-death path). A node that
// restarted (CreateCluster → 1-node standalone) or detached
// (detachAsStandalone → 1-node standalone) is NOT the multi-node ring
// leader the predecessor thinks it is. Returning 409 makes the
// predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader
// → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader
// 退出节点时让leader上邻居意识到当前环已经没有节点了".
if r.Body == nil {
if s := h.Ring.State(); len(s.Nodes) <= 1 {
http.Error(w, "standalone node", http.StatusConflict)
return
}
w.WriteHeader(http.StatusOK)
return
}
body, _ := io.ReadAll(r.Body)
if len(bytes.TrimSpace(body)) == 0 {
if s := h.Ring.State(); len(s.Nodes) <= 1 {
http.Error(w, "standalone node", http.StatusConflict)
return
}
w.WriteHeader(http.StatusOK)
return
}
@ -448,6 +557,14 @@ func (h *Handler) handleClusterJoin(w http.ResponseWriter, r *http.Request) {
http.Error(w, "parse join: "+err.Error(), http.StatusBadRequest)
return
}
// Verify the newcomer presents this node's admission key. Without this
// check, any client that knows the shared webui password could join and
// receive the full cluster picture — the key adds a per-node admission
// layer the operator must copy from the sponsor's cluster page.
if ji.JoinKey == "" || ji.JoinKey != h.Ring.NodeKey() {
http.Error(w, "invalid join key", http.StatusForbidden)
return
}
wasSingle := len(h.Ring.State().Nodes) <= 1
state := h.Ring.JoinNode(ji)
writeJSON(w, http.StatusOK, map[string]any{"state": state})
@ -547,7 +664,8 @@ func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request)
return
}
var req struct {
Addr string `json:"addr"`
Addr string `json:"addr"`
JoinKey string `json:"joinKey"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
@ -557,13 +675,17 @@ func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request)
http.Error(w, "addr required", http.StatusBadRequest)
return
}
if req.JoinKey == "" {
http.Error(w, "joinKey required", http.StatusBadRequest)
return
}
if h.Ring.IsMember() {
http.Error(w, "already a multi-node cluster member; leave first", http.StatusConflict)
return
}
jc, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.Ring.JoinRingAddr(jc, req.Addr); err != nil {
if err := h.Ring.JoinRingAddr(jc, req.Addr, req.JoinKey); err != nil {
http.Error(w, "join failed: "+err.Error(), http.StatusBadGateway)
return
}

View File

@ -211,7 +211,7 @@ func TestSaveCanvasPublishesRevokeTask(t *testing.T) {
})
ring := cluster.NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
&cluster.AppHandler{}, func(ctx context.Context, next string, tk *cluster.Token) error { return nil },
"n1:7500", true)
"n1:7500", true, "")
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw", Ring: ring}
mux, _ := NewServeMux(h)
ts := httptest.NewServer(mux)

View File

@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"sync"
"syscall"
"time"
@ -272,6 +273,20 @@ func (m *Manager) Status(name string) (Status, bool) {
return m.getStatus(w), true
}
// ListWorkers returns the names of all workers currently under management
// (running, starting, crashed-but-supervised, etc.), sorted for stable output.
// Stopped workers are removed from the map and not listed.
func (m *Manager) ListWorkers() []string {
m.mu.Lock()
out := make([]string, 0, len(m.workers))
for name := range m.workers {
out = append(out, name)
}
m.mu.Unlock()
sort.Strings(out)
return out
}
// StopAll stops all workers (used on manager shutdown).
func (m *Manager) StopAll() {
m.mu.Lock()

View File

@ -2,13 +2,20 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
@ -93,6 +100,14 @@ type Link struct {
RemotePort int `json:"remotePort"`
OffsetX int `json:"offsetX,omitempty"`
OffsetY int `json:"offsetY,omitempty"`
// Group is a user-facing management label for one-click group start/stop on
// the forwards page (unrelated to frps load-balancing LBGroup on Local).
Group string `json:"group,omitempty"`
// Disabled marks a forward as stopped. renderRemote skips it (so a stopped
// local-only forward drops just its own proxy), and applyCanvas reconciles
// to topology respecting it (disabled forwards are not re-submitted). This
// makes per-forward stop durable across canvas saves. Zero value = enabled.
Disabled bool `json:"disabled,omitempty"`
}
// Settings holds runtime options.
@ -101,6 +116,18 @@ type Settings struct {
RestartOnExit bool `json:"restartOnExit"`
RestartIntervalSeconds int `json:"restartIntervalSeconds"`
BinaryPath string `json:"binaryPath,omitempty"`
// NodeKey is this node's cluster admission key. A newcomer must present
// the sponsor's NodeKey to join via it (handleClusterJoin verifies).
// Generated on first startup, persisted, stable across restarts so the
// -join-key bootstrap path stays valid. Unrelated to the webui Basic-Auth
// creds (-user/-password), which remain the transport-level credential.
NodeKey string `json:"nodeKey,omitempty"`
// ClusterPeers is a JSON array of {addr,key} pairs for all known cluster
// peers, persisted on every token cycle. On crash/restart the node reads
// this and tries to rejoin via any cached peer (presenting that peer's
// key). Cleared on explicit detach (detachAsStandalone) so a node that
// intentionally left does NOT auto-rejoin.
ClusterPeers string `json:"clusterPeers,omitempty"`
}
// Forward is a rendered link row attached to a remote.
@ -110,6 +137,37 @@ type Forward struct {
LocalPort int `json:"localPort,omitempty"`
OffsetX int `json:"offsetX,omitempty"`
OffsetY int `json:"offsetY,omitempty"`
// Disabled mirrors the link's Disabled so renderRemote can skip stopped
// forwards when building the frpc proxy list for a remote.
Disabled bool `json:"disabled,omitempty"`
}
// User is an authenticated account. Role gates UI/API access (admin = full,
// viewer = read-only + exports, for auditors). System users are synced from
// the -user/-password flags and are read-only in the account-management UI.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"` // never serialized to clients
Role string `json:"role"` // "admin" | "viewer"
Enabled bool `json:"enabled"`
System bool `json:"system"` // true = flag-synced, UI read-only
CreatedAt int64 `json:"createdAt"`
LastLoginAt int64 `json:"lastLoginAt"`
}
// ApiKey is a bearer token bound to a user with an explicit scope. The
// plaintext key is returned exactly once at creation; only its sha256 hash
// and an 8-char display prefix are persisted.
type ApiKey struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
Prefix string `json:"prefix"` // first 8 chars of plaintext, for display
Label string `json:"label"`
Scope string `json:"scope"` // "read" | "write" | "admin"
CreatedAt int64 `json:"createdAt"`
LastUsedAt int64 `json:"lastUsedAt"`
ExpiresAt int64 `json:"expiresAt"` // 0 = never expires
}
const schema = `
@ -158,13 +216,37 @@ CREATE TABLE IF NOT EXISTS links (
remote TEXT NOT NULL REFERENCES remotes(name) ON DELETE CASCADE,
remote_port INTEGER NOT NULL DEFAULT 0,
offset_x INTEGER NOT NULL DEFAULT 0,
offset_y INTEGER NOT NULL DEFAULT 0
offset_y INTEGER NOT NULL DEFAULT 0,
grp TEXT NOT NULL DEFAULT '',
disabled INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_links_remote ON links(remote);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer'
enabled INTEGER NOT NULL DEFAULT 1,
system INTEGER NOT NULL DEFAULT 0, -- 1 = synced from -user/-password flags, UI read-only
created_at INTEGER NOT NULL DEFAULT 0,
last_login_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT UNIQUE NOT NULL, -- sha256(plaintext) hex
prefix TEXT NOT NULL DEFAULT '', -- first 8 chars of plaintext, for display
label TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'admin'
created_at INTEGER NOT NULL DEFAULT 0,
last_used_at INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL DEFAULT 0 -- 0 = never expires
);
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
`
// Store is the SQLite persistence layer.
@ -240,6 +322,10 @@ func (s *Store) migrate() error {
"admin_user TEXT NOT NULL DEFAULT ''",
"admin_password TEXT NOT NULL DEFAULT ''",
},
"links": {
"grp TEXT NOT NULL DEFAULT ''",
"disabled INTEGER NOT NULL DEFAULT 0",
},
}
for table, cols := range tables {
rows, err := s.db.Query("PRAGMA table_info(" + table + ")")
@ -432,7 +518,7 @@ func (s *Store) DeleteRemote(name string) error {
// ---- Links ----
func (s *Store) ListLinks() ([]Link, error) {
rows, err := s.db.Query("SELECT id, local, remote, remote_port, offset_x, offset_y FROM links")
rows, err := s.db.Query("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links")
if err != nil {
return nil, err
}
@ -440,7 +526,7 @@ func (s *Store) ListLinks() ([]Link, error) {
var out []Link
for rows.Next() {
var l Link
if err := rows.Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY); err != nil {
if err := rows.Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled); err != nil {
return nil, err
}
out = append(out, l)
@ -448,6 +534,37 @@ func (s *Store) ListLinks() ([]Link, error) {
return out, rows.Err()
}
// AddLink inserts a single link row and returns it with the new id filled in.
func (s *Store) AddLink(l Link) (Link, error) {
res, err := s.db.Exec(
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y, grp, disabled) VALUES(?,?,?,?,?,?,?)",
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY, l.Group, l.Disabled,
)
if err != nil {
return Link{}, err
}
id, _ := res.LastInsertId()
l.ID = id
return l, nil
}
// GetLink returns a single link by id.
func (s *Store) GetLink(id int64) (Link, bool) {
var l Link
err := s.db.QueryRow("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links WHERE id = ?", id).
Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled)
if err != nil {
return Link{}, false
}
return l, true
}
// DeleteLink removes a single link by id.
func (s *Store) DeleteLink(id int64) error {
_, err := s.db.Exec("DELETE FROM links WHERE id = ?", id)
return err
}
// LocalTarget describes one outgoing forward of a local service.
type LocalTarget struct {
Remote string `json:"remote"`
@ -491,6 +608,7 @@ func (s *Store) LinksForRemote(remote string) ([]Forward, error) {
LocalPort: loc.Port,
OffsetX: l.OffsetX,
OffsetY: l.OffsetY,
Disabled: l.Disabled,
})
}
return out, nil
@ -508,8 +626,8 @@ func (s *Store) ReplaceLinks(links []Link) error {
}
for _, l := range links {
if _, err := tx.Exec(
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y) VALUES(?,?,?,?,?)",
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY,
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y, grp, disabled) VALUES(?,?,?,?,?,?,?)",
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY, l.Group, l.Disabled,
); err != nil {
return err
}
@ -517,6 +635,31 @@ func (s *Store) ReplaceLinks(links []Link) error {
return tx.Commit()
}
// SetLinkDisabled flips the disabled flag of a forward identified by its
// (local, remote, remotePort) natural key. This is the persistence half of the
// forwards-page start/stop toggle; the caller also drives the worker/ring side.
func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) error {
_, err := s.db.Exec(
"UPDATE links SET disabled = ? WHERE local = ? AND remote = ? AND remote_port = ?",
disabled, local, remote, port,
)
return err
}
// SetLinkGroup assigns a management group label to a forward identified by its
// (local, remote, remotePort) natural key. Empty string clears the group
// (moves the forward to 未分组). This is the persistence half of the
// status-page group chip edit; the canvas editor also writes group via
// saveCanvas. Group is for one-click start/stop on the forwards page only
// (unrelated to frps load-balancing lbGroup on Local).
func (s *Store) SetLinkGroup(local, remote string, port int, group string) error {
_, err := s.db.Exec(
"UPDATE links SET grp = ? WHERE local = ? AND remote = ? AND remote_port = ?",
group, local, remote, port,
)
return err
}
// ---- Settings ----
func (s *Store) Settings() (Settings, error) {
@ -544,6 +687,28 @@ func (s *Store) UpdateSettings(st Settings) error {
return err
}
// SetNodeKey persists just the cluster admission key, preserving all other
// settings. Used on first startup when the key is generated.
func (s *Store) SetNodeKey(key string) error {
st, err := s.Settings()
if err != nil {
return err
}
st.NodeKey = key
return s.UpdateSettings(st)
}
// SetClusterPeers persists the cached peer list (JSON) so a crashed node can
// auto-rejoin on restart. Pass "" to clear (explicit detach).
func (s *Store) SetClusterPeers(peersJSON string) error {
st, err := s.Settings()
if err != nil {
return err
}
st.ClusterPeers = peersJSON
return s.UpdateSettings(st)
}
var (
ErrNotFound = errors.New("not found")
ErrInvalid = errors.New("invalid argument")
@ -556,3 +721,264 @@ func boolToInt(b bool) int {
}
return 0
}
// ---- Users ----
func (s *Store) ListUsers() ([]User, error) {
rows, err := s.db.Query("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var en, sys int
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
return nil, err
}
u.Enabled = en != 0
u.System = sys != 0
out = append(out, u)
}
return out, rows.Err()
}
func (s *Store) GetUser(username string) (User, bool) {
var u User
var en, sys int
row := s.db.QueryRow("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users WHERE username = ?", username)
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
return User{}, false
}
u.Enabled = en != 0
u.System = sys != 0
return u, true
}
func (s *Store) GetUserByID(id int64) (User, bool) {
var u User
var en, sys int
row := s.db.QueryRow("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users WHERE id = ?", id)
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
return User{}, false
}
u.Enabled = en != 0
u.System = sys != 0
return u, true
}
// CreateUser inserts a new user, hashing the plaintext password with bcrypt.
func (s *Store) CreateUser(username, plainPassword, role string) (User, error) {
if username == "" || plainPassword == "" {
return User{}, ErrInvalid
}
if role != "admin" && role != "viewer" {
return User{}, ErrInvalid
}
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
if err != nil {
return User{}, err
}
now := time.Now().Unix()
res, err := s.db.Exec(
"INSERT INTO users(username, password_hash, role, enabled, system, created_at, last_login_at) VALUES(?,?,?,1,0,?,0)",
username, string(hash), role, now,
)
if err != nil {
return User{}, err
}
id, _ := res.LastInsertId()
return User{
ID: id, Username: username, PasswordHash: string(hash),
Role: role, Enabled: true, System: false, CreatedAt: now,
}, nil
}
// UpdateUser modifies role/enabled and optionally resets the password.
// System (flag-synced) users refuse password changes.
func (s *Store) UpdateUser(id int64, role string, enabled bool, plainPassword string) error {
u, ok := s.GetUserByID(id)
if !ok {
return ErrNotFound
}
if role != "admin" && role != "viewer" {
return ErrInvalid
}
if u.System && plainPassword != "" {
return ErrInvalid
}
if plainPassword != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.db.Exec(
"UPDATE users SET password_hash=?, role=?, enabled=? WHERE id=?",
string(hash), role, boolToInt(enabled), id,
)
return err
}
_, err := s.db.Exec(
"UPDATE users SET role=?, enabled=? WHERE id=?",
role, boolToInt(enabled), id,
)
return err
}
// DeleteUser removes a user. System users are protected. Callers should guard
// the last remaining admin with CountAdmins before deleting an admin.
func (s *Store) DeleteUser(id int64) error {
u, ok := s.GetUserByID(id)
if !ok {
return ErrNotFound
}
if u.System {
return ErrInvalid
}
_, err := s.db.Exec("DELETE FROM users WHERE id = ?", id)
return err
}
// CountAdmins returns the count of enabled admin users (for the last-admin guard).
func (s *Store) CountAdmins() (int, error) {
var n int
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1").Scan(&n)
return n, err
}
// SyncSystemUser upserts the flag-synced built-in admin account on every
// startup so -user/-password changes propagate to the users table. A
// pre-existing non-system row with the same name is left untouched; the
// flag-creds fallback in the auth middleware still authenticates it.
func (s *Store) SyncSystemUser(username, plainPassword string) error {
if username == "" || plainPassword == "" {
return ErrInvalid
}
existing, ok := s.GetUser(username)
if ok && !existing.System {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
now := time.Now().Unix()
if ok {
_, err = s.db.Exec(
"UPDATE users SET password_hash=?, role='admin', enabled=1, system=1 WHERE id=?",
string(hash), existing.ID,
)
return err
}
_, err = s.db.Exec(
"INSERT INTO users(username, password_hash, role, enabled, system, created_at, last_login_at) VALUES(?,?,?,1,1,?,0)",
username, string(hash), "admin", now,
)
return err
}
// TouchUserLogin records a successful login timestamp.
func (s *Store) TouchUserLogin(id int64) error {
_, err := s.db.Exec("UPDATE users SET last_login_at = ? WHERE id = ?", time.Now().Unix(), id)
return err
}
// VerifyUserPassword returns the user when the bcrypt hash matches. Used by
// the auth middleware's Basic branch.
func (s *Store) VerifyUserPassword(username, plainPassword string) (User, bool) {
u, ok := s.GetUser(username)
if !ok || !u.Enabled {
return User{}, false
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(plainPassword)) != nil {
return User{}, false
}
return u, true
}
// ---- API keys ----
func (s *Store) ListApiKeys() ([]ApiKey, error) {
rows, err := s.db.Query("SELECT id, user_id, prefix, label, scope, created_at, last_used_at, expires_at FROM api_keys ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
var out []ApiKey
for rows.Next() {
var k ApiKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Prefix, &k.Label, &k.Scope, &k.CreatedAt, &k.LastUsedAt, &k.ExpiresAt); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// CreateApiKey generates a 32-byte random key, stores its sha256 hash, and
// returns the plaintext exactly once.
func (s *Store) CreateApiKey(userID int64, label, scope string) (ApiKey, string, error) {
if scope != "read" && scope != "write" && scope != "admin" {
return ApiKey{}, "", ErrInvalid
}
if _, ok := s.GetUserByID(userID); !ok {
return ApiKey{}, "", ErrNotFound
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return ApiKey{}, "", err
}
// "w4f_" prefix makes keys greppable/recognizable; base64 RawURL = no padding.
plaintext := "w4f_" + base64.RawURLEncoding.EncodeToString(raw)
hash := HashApiKey(plaintext)
prefix := plaintext[:8]
now := time.Now().Unix()
res, err := s.db.Exec(
"INSERT INTO api_keys(user_id, key_hash, prefix, label, scope, created_at, last_used_at, expires_at) VALUES(?,?,?,?,?,?,0,0)",
userID, hash, prefix, label, scope, now,
)
if err != nil {
return ApiKey{}, "", err
}
id, _ := res.LastInsertId()
return ApiKey{
ID: id, UserID: userID, Prefix: prefix, Label: label,
Scope: scope, CreatedAt: now,
}, plaintext, nil
}
// LookupApiKey finds a key by the sha256 hex of its plaintext, validating
// expiry and the owning user's enabled flag. Used by the Bearer branch.
func (s *Store) LookupApiKey(hashHex string) (ApiKey, User, bool) {
var k ApiKey
row := s.db.QueryRow("SELECT id, user_id, prefix, label, scope, created_at, last_used_at, expires_at FROM api_keys WHERE key_hash = ?", hashHex)
if err := row.Scan(&k.ID, &k.UserID, &k.Prefix, &k.Label, &k.Scope, &k.CreatedAt, &k.LastUsedAt, &k.ExpiresAt); err != nil {
return ApiKey{}, User{}, false
}
if k.ExpiresAt != 0 && time.Now().Unix() > k.ExpiresAt {
return ApiKey{}, User{}, false
}
u, ok := s.GetUserByID(k.UserID)
if !ok || !u.Enabled {
return ApiKey{}, User{}, false
}
return k, u, true
}
// TouchApiKey records the last-used timestamp for a key.
func (s *Store) TouchApiKey(id int64) error {
_, err := s.db.Exec("UPDATE api_keys SET last_used_at = ? WHERE id = ?", time.Now().Unix(), id)
return err
}
func (s *Store) DeleteApiKey(id int64) error {
_, err := s.db.Exec("DELETE FROM api_keys WHERE id = ?", id)
return err
}
// HashApiKey computes the sha256 hex of a plaintext key (middleware helper).
func HashApiKey(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}

View File

@ -0,0 +1,114 @@
package store
import (
"path/filepath"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
st, err := New(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func TestUserCreateVerify(t *testing.T) {
st := newTestStore(t)
u, err := st.CreateUser("alice", "secret-pass", "viewer")
if err != nil {
t.Fatalf("create: %v", err)
}
if u.Role != "viewer" || u.System || !u.Enabled {
t.Fatalf("bad user: %+v", u)
}
// Wrong password must fail.
if _, ok := st.VerifyUserPassword("alice", "wrong"); ok {
t.Fatal("verify accepted wrong password")
}
// Correct password must succeed.
got, ok := st.VerifyUserPassword("alice", "secret-pass")
if !ok || got.Username != "alice" || got.Role != "viewer" {
t.Fatalf("verify failed: %+v ok=%v", got, ok)
}
// Duplicate username must error.
if _, err := st.CreateUser("alice", "x", "admin"); err == nil {
t.Fatal("duplicate create should fail")
}
}
func TestSystemUserSync(t *testing.T) {
st := newTestStore(t)
if err := st.SyncSystemUser("admin", "flag-pass"); err != nil {
t.Fatalf("sync: %v", err)
}
u, ok := st.GetUser("admin")
if !ok || !u.System || u.Role != "admin" {
t.Fatalf("system user not synced: %+v ok=%v", u, ok)
}
// Flag password change propagates on re-sync.
if err := st.SyncSystemUser("admin", "new-flag-pass"); err != nil {
t.Fatalf("re-sync: %v", err)
}
if _, ok := st.VerifyUserPassword("admin", "flag-pass"); ok {
t.Fatal("old flag password still works after re-sync")
}
if _, ok := st.VerifyUserPassword("admin", "new-flag-pass"); !ok {
t.Fatal("new flag password rejected after re-sync")
}
}
func TestLastAdminGuard(t *testing.T) {
st := newTestStore(t)
// System admin exists after sync; count must be 1.
if err := st.SyncSystemUser("admin", "p"); err != nil {
t.Fatal(err)
}
n, _ := st.CountAdmins()
if n != 1 {
t.Fatalf("admins = %d want 1", n)
}
// System user delete must be refused.
if err := st.DeleteUser(1); err != ErrInvalid {
t.Fatalf("delete system user: %v want ErrInvalid", err)
}
// Add a viewer, then disable the system admin: still 1 admin, allowed at
// store level (handler enforces the guard, store just refuses system rows).
if _, err := st.CreateUser("bob", "p", "viewer"); err != nil {
t.Fatal(err)
}
}
func TestApiKeyCreateLookup(t *testing.T) {
st := newTestStore(t)
u, _ := st.CreateUser("carol", "p", "admin")
k, plaintext, err := st.CreateApiKey(u.ID, "ci", "write")
if err != nil {
t.Fatalf("create key: %v", err)
}
if plaintext == "" || k.Prefix == "" {
t.Fatalf("empty plaintext/prefix: %+v", k)
}
if plaintext[:8] != k.Prefix {
t.Fatalf("prefix mismatch: %q vs %q", k.Prefix, plaintext[:8])
}
// Lookup via the sha256 of the plaintext must find the key + owner.
found, owner, ok := st.LookupApiKey(HashApiKey(plaintext))
if !ok || owner.ID != u.ID || found.Scope != "write" {
t.Fatalf("lookup failed: %+v owner=%+v ok=%v", found, owner, ok)
}
// A bogus hash must miss.
if _, _, ok := st.LookupApiKey(HashApiKey("not-a-real-key")); ok {
t.Fatal("bogus key should not be found")
}
// Deleting the user cascades to the key (FK ON DELETE CASCADE).
if err := st.DeleteUser(u.ID); err != nil {
t.Fatalf("delete user: %v", err)
}
if _, _, ok := st.LookupApiKey(HashApiKey(plaintext)); ok {
t.Fatal("key survived user deletion")
}
}

193
plan.md
View File

@ -1,193 +0,0 @@
# webui4frpc 开发计划
> webui4frpc —— 独立于 frp 源码的可视化 frpc 控制器(单二进制,零 frp 代码依赖frpc 运行时一键从官方 Releases 下载或手动指定)。
## 目标
用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,避免运维反复手写 frpc 配置。
## 已完成M0骨架与核心
- [x] 独立仓库module `webui4frpc`,仅依赖 `modernc.org/sqlite`,零 frp 源码引用
- [x] 目录结构cmd / internal/{store,render,process,httpapi,install} + webVue3 + VueFlow + Element Plus
- [x] 画布模型local / remote / link 多对多连线,节点可编辑、可添加
- [x] 渲染器tcp/udp/http/https → frpc JSON 配置http 用 customDomains=`<name>.local`
- [x] worker 进程管理spawn/stop/restart、日志轮转、崩溃自愈指数退避
- [x] 一键安装 frpcGitHub Releases 手动指定路径
- [x] REST APIstatus/canvas/settings/remotes/profiles/binary
- [x] 三页 UI状态默认/ 连接配置 / 设置
- [x] 状态页:远程节点状态框 + 本地服务转发表(每 5s 轮询),节点可增删改启停
- [x] 画布可用性检查:端口冲突 / http 域名唯一性,标红 + 弹窗提示,保存前拦截
- [x] 演示环境3 本地 × 3 远程多对多全连通tcp + http 转发实测返回数据)
- [x] 后端单元测试store / render / process / httpapi / install
## 项目现状核对(代码审查记录 2024-08 单 commit c193688
- 后端入口 `cmd/webui4frpc/main.go`flag 解析(-addr/-user/-password/-workdir/-bin→ 建数据目录 → `store.New``process.NewManager`BinaryPath/AutoRestart/RestartInterval 动态闭包AutoStartProfiles 启动已启用 worker`httpapi.NewServeMux`healthz 免认证,其余 Basic Auth→ SIGINT/SIGTERM 优雅退出15s 超时 + StopAll
- 核心数据流PUT `/api/manager/canvas` → store 全量 upsert/删除 + 事务替换 links → `SyncWorkers` 重启受影响 worker → 每台远程独立 `frpc -c configs/<name>.json` 进程
- 渲染器当前能力tcp/udp 用 remotePorthttp/https 用 customDomains默认 `<name>.local`重名服务自动加端口后缀transport 段仅有 protocol 字段且从未填充
- 前端App.vue 三页手动切换(状态/连接配置/设置CanvasView/VueFlow 画布 + StatusView 5s 轮询 + SettingsView
## 已知问题(审查发现,待修复)
1. ~~设置保存失效P0~~ **已修复**`server.go``NewServeMux` 现在对 `PUT /api/manager/settings` 分发到 `handleSettingsPut`,新增 `TestSettingsPutRoundTrip` 回归测试,前端 SettingsView 保存生效。
2. ~~dev proxy 端口不一致~~ **已修复**`web/vite.config.ts` proxy 指向 `127.0.0.1:7500`(可用 `VITE_PROXY_TARGET` 覆盖),与后端默认一致。
3. **冗余依赖**`vue-router` 在 package.json 依赖中但未被使用App.vue 以视图 ref 手动切页,无路由)。
4. **README 笔误**:架构图写 `cmds/webui4frpc`,实际目录为 `cmd/webui4frpc`
## 里程碑
### M1 高级传输参数P0建议优先
目标:让常用 frpc 能力可配置,保持画布简洁(折叠高级区)。
- [x] Local 增加高级字段useEncryption / useCompression / bandwidthLimit / poolCount / metadatas / annotations渲染器 + 编辑表单 + 折叠 UI
- [x] Remote 增加 transportprotocoltcp/quic/kcp/websocket、tls.enable、poolCount
- [x] 渲染器补齐 transport 段输出;增加 render 单测覆盖新字段
- [x] 高级字段随 canvas API 持久化store 表扩展 + 迁移)
### M2 HTTP/HTTPS 完善
- [x] local http/https 增加可编辑域名字段customDomains / subdomain
- [x] locations路径路由多配置
- [x] httpHeaderRewrite / hostHeaderRewrite / basicAuth站点访问认证
- [x] frps vhostHTTPPort 的显示与状态映射(状态页展示)
### M3 负载均衡与健康检查
- [x] 模型扩展Local 增加 lbGroup / lbGroupKey / healthCheck* 字段Remote 增加 adminAddr / adminPort / adminUser / adminPasswordlocal frpc admin API
- [x] 渲染器proxy 输出 loadBalancer + healthCheck 段worker 配置输出 webServer 段frpc >= 0.52 支持)
- [x] 状态页:按 admin API GET /api/status 拉取真实 per-proxy 状态running / check failed / wait start / start error / new / closed未启用 admin 时回退日志推断
- [x] 前端LocalNode/RemoteNode 表单 + StatusView per-proxy 状态与 LB 组徽标vue-tsc 通过)
- [x] CLUSTER 页展示负载均衡组group+ 健康检查状态e2e 验证backend 停止→check failed 剔除,恢复→自动收回)
### M4 更多代理类型
- [ ] tcpmuxHTTP/2 多路复用)
- [ ] stcp / sudp点对点加密穿透
- [ ] xtcpP2P需 kcp
- [ ] 按类型动态显示字段的表单
### M5 运维与发布
- [x] git init + 首次 commit + .gitignore排除 node_modules / web/dist.pi/ 本地代理状态已忽略)
- [ ] 一键构建脚本web→dist→embed→单二进制
- [ ] GitHub Actionsrelease 构建linux/windows/mac
- [ ] 配置导出/导入(备份)
- [ ] 日志查看页worker 日志 tail UI
> 注:发布类条目已并入 **M7 运维与发布(原 M5**,本节 M5 保留初始清单。
>
### M6 集群(令牌环网拓扑)
> **权威设计(用户约定,必须固化)**:集群节点间通过**令牌环网token ring**通信,单轮为一个周期。
#### 拓扑与角色
- 集群 = 若干 webui4frpc 节点组成的内网协作网络;逻辑上各节点**平等**,均持有**完整集群信息**(节点表、转发链、各自配置)与完整 webui。
- **leader**:唯一特殊角色,负责启动时发送第一个令牌;默认**创建集群的节点**为初始 leader。
- **转发链ring**:令牌按固定顺序传递;每个节点记录前驱/后继。
#### 令牌环协议(单轮:边传播边同步,收敛快半轮)
- **令牌环行时,各节点收到令牌后同时做两件事**
1. **透传/同步**:采纳令牌已携带的完整集群信息(其它节点此前追加的节点表、负载、拓扑、日志增量),更新自己的完整集群视图。
2. **追加/顺带更新**把自己的信息地址、负载指标、frpc 缓存、转发能力、本机产生的命令)追加进令牌。
- 因每经过一个节点就立即传播当前已知的最新状态,第一个节点的信息在**第二个节点**就被同步到,全网收敛只需**约半圈**——无需分两轮——第一轮即同步,全网收敛只需约半圈。
- 令牌同一时刻只有一个;由"收到令牌的节点处理后交给下家"驱动,无定时重启、无竞态。
- **节奏控制(并行计时器 + 同步操作,非硬性 sleep**:令牌到达时节点做自己的操作(摘取/追加/日志同步/差异预计算),**同时起一个独立的节奏计时器**。操作完成后若计时器未走完则等计时器(保持环的稳定节奏);计时器先到而操作未完则等操作完成(操作皆轻量,且大部分耗时操作如差异计算在令牌到来前已完成)。故转发时机 = max(操作完成, 节奏计时器),两者并行不互斥。
- **令牌带时间戳,只取最新**:每个令牌携带发起时间戳(`SentAt`);节点收到令牌时比较时间戳——若令牌比本机最近记录的旧,说明是**重发残留的旧令牌**,直接丢弃。此机制彻底消除"重发时旧令牌可能还活着"的担忧,即使重发,旧令牌也会被下游识别过期而消失,环上始终只有一个有效令牌。
- 新转发任务:需要建立转发时,**把任务附加在令牌中**(而非直接指派);因每节点都持完整集群信息,由**负载最低的节点****内存使用率 + 网络使用率共同判断**)自行摘取并创建转发。
- **负载摘取**:令牌传递到某节点时,若该节点在上一轮被判定为负载最低,则**先主动摘取任务**,随后在令牌中更新自身信息。
#### 容错与邻居离线
- 令牌传递超时(未收到回执)→ 判定邻居离线。因每节点持完整集群信息,当前节点可**自动修改集群状态**,并把邻居负责的转发**作为新任务追加到令牌**,再传给下一节点。
- leader 检测令牌丢失:发出令牌后超过 **(轮次延迟 / 2 + 20ms)** 未回传 → 判定令牌丢失(可能某节点接令后崩溃),**重发令牌**。
- leader 每轮探测后更新一次轮次延迟。
#### leader 补充/监控
- leader 受其上家邻居监控:二者**交换心跳包**(因节点都持完整信息,容易做到)。
- 上家邻居探测到 leader 崩溃 → **自身成为新 leader**
#### 新节点加入
- 每个节点都有 webui用户登录到哪个节点的 webui就由**该节点**执行新节点加入:令牌发到自己时,**先转发给新节点**,并把新节点加入令牌中的集群信息,**更新转发链**:新节点放在自己后面,自己原本的后继放在新节点之后。
#### 增量日志同步(本机事件 → 令牌 → 全网一致)
- 每个节点维护一份**集群事件日志operation log**:本机发生的变更(创建/删除转发、节点加入/离开、负载变化、leader 变更)以**递增序号**追加为本机日志条目。
- 令牌携带**本机日志增量**(同步与追加在同一轮):号码 > 上一周期已同步水位(`lastSyncedSeq`)的条目,随令牌环行时让其它节点**按序追加重放到本地日志**,从而实现全网**增量日志同步**。
- 日志条目不可变(追加式);节点收到增量后校验序号连续性(缺失则请求补齐),因每个节点持有完整集群信息,可从其它节点补拉。
- 集群状态(拓扑/任务/leader可**由日志重放得到**:新节点加入时,通过令牌/邻居拉取完整日志并重放,即可获得与其它节点一致的完整视图(满足"每个节点都掌握完整集群信息")。
- 该机制与"令牌承载中间配置文件/转发拓扑"正交共存:令牌同时携带〔拓扑快照〕与〔日志增量〕,快照用于即时校验,日志用于一致性追补。
#### 任务撤销(复用任务发布通道,撤销语义)
- 撤销一个转发 = **发布一个"撤销任务"到令牌**与任务发布走同一基础通道round-1 注入、round-2 执行),只是 Type 为撤销、载荷为〔任务 ID + 该转发中间配置〕。
- 令牌环行时,**持有该转发的节点**(其 owner 记录在拓扑中)收到撤销任务后:取消本地 frpc worker → 从活跃拓扑移除该转发 → 记 `forward.remove` 增量日志,全网随之下一次轮次收敛一致。
- 撤销任务幂等:若转发已不在拓扑(已被撤销/离线重挂),收到撤销仅记日志、不报错。
- localOnly 的本地转发撤销不发布到令牌,直接本机取消(因本机即 owner仅本节点可见
#### 画布差异判断diff与命令追加
- **在哪个节点修改 webui 画布,就由该节点进行差异判断**:将该节点画布的「期望状态」与当前集群拓扑(该节点持有的完整视图)比对。
- 差异结果转为**一批转发/撤销命令,追加到令牌任务**
- 画布有、拓扑无 → 追加**新增转发命令**(任务+中间配置)→ 后续由负载最低者摘取创建。
- 画布无、拓扑有 → 追加**撤销转发命令**(撤任务+转发的中间配置)→ 持有者收到后取消 worker 并移出拓扑。
- 画布即唯一编辑入口:编辑节点只发命令,不直接改其它节点;拓扑随令牌轮次收敛全网一致。
- **其它节点根据集群拓扑生成本地画布存储**非编辑节点收到令牌同步的完整拓扑后把拓扑中的转发locals/remotes/links重建为本地画布视图叠加本节点的 localOnly 项。故所有节点的画布展示一致(都源自拓扑),编辑只在入口节点生效。
#### 增删节点
- **加入节点addnode**:维持原设计——在**哪个节点的 webui 做添加节点,哪个节点负责把新节点并入集群**(临近节点处理):
1. 临近节点(如 B收到新节点C的加入请求后**修改转发链与拓扑**:把 C 插入自己后面B 的后继=C自己的原后继A移到 C 后面C 的后继=A形成 `A→B→C→A`
2. **B 将令牌交给 C**(而非原后继 A——C 作为 B 的后继参与环C 处理令牌后继续传给自己的后继 A**形成完整的 join 语义**。
3. 随令牌轮转,全节点收敛到含 C 的一致拓扑。
- **移除节点removenode****借用现有任务发布基础设施**——在某节点 webui 发起删除节点,发布 `node.remove` 命令进令牌;令牌传递到**被移除节点自身**时,该节点执行自移除:
1. 将自己负责的转发任务**重新追加到待分配任务**(自然的 `owner 离线重挂` 语义),
2. **修改拓扑与转发链、移除自身**(拓扑去掉自己的项、环去掉自己、后继衔接),
3. 令牌传递给自身**原本的下一家**。
- **脱离集群后的本地视图**:被移除节点脱离后,自身内部维持的拓扑**只保留本地 locallocalOnly 不进集群的转发)****完全取消一切集群远程转发**(删掉由集群分发的转发、停其 worker
#### 故障自愈(传递令牌间隙判定邻居离线)
- 邻居离线判定**仅在传递令牌的间隙**发生:上家传递令牌给下家后,若在预期窗口内未收到回执,则判定下家离线 → **修改集群状态、移除该节点、将其负责的任务重新追加为待办**,令牌传给再下家。
#### 节点消亡(防脑裂最强兜底)
- 任何节点若**超时收不到令牌**(超过既定周期的 N 倍),**直接自杀退出**不探测上家、不生成新令牌、不做任何协商。这是防止集群脑裂split-brain的最后防线——宁可单一节点自毁不让两个各自为政的残环长期并存。
#### 实现里程碑(待按上述设计重建)
- [x] 数据面Ring State节点表/转发链/leader/轮次延迟/负载/待办任务)+ 日志(追加式 + 水位)
- [x] 令牌环传输HTTP POST /cluster/token + 回执;单轮一周期(信息追加与同步同步完成)
- [x] leader创建集群者为首 leaderStartRing 首令牌 + 轮次延迟测量 + 令牌丢失重发LossTimeout
- [x] 新转发任务POST /cluster/task 挂 pending → 令牌携带 → 负载最低者摘取 → 持久化 + 拉 workere2e 验证)
- [x] 增量日志同步LogForwardAdd/LogNodeJoin 等事件随令牌 delta 传播单轮内完成同步各节点重放收敛e2e 验证)
- [x] 拓扑回写:摘取后 state 写入令牌全网一致e2e topology=[t1,owner]
- [x] 新节点加入POST /cluster/join → 插入环(后继) → AdoptStatee2e A+B 环)
- [ ] 离线处理:令牌超时 → 改集群状态 → 邻居转发转任务重挂forwardToNext 已实现,待 e2e
- [ ] leader 监控:上家邻居心跳探测 → 崩溃提升WatchLeader 已实现,待 e2e
- [ ] 前端:集群页展示环拓扑/令牌轮次/负载/任务摘取;设置页二进制缓存状态
### M7 运维与发布(原 M5
- [x] git init + 首次 commit + .gitignore
- [ ] 一键构建脚本 / GitHub Actions release / 配置导入导出 / 日志查看页
## 建议推进顺序
1. [x] 修复「设置保存失效」——给 `PUT /api/manager/settings` 补注册server.go 方法分发 + httpapi 单测)
2. [x] 对齐 dev proxy 端口——vite.config.ts → 7500支持 VITE_PROXY_TARGET 覆盖)
3. M1 高级传输参数store 加列 → render 输出 → 前端折叠表单 → 单测回归)
4. M3 负载均衡与健康检查(模型 → 渲染 → admin API 状态 → 集群页)— 进行中
5. M6 集群内 frpc 二进制分发(节点间优先 → 外部 URL 兜底 → 新节点自动传输)
## 审查结论摘要(详见 FRPC_FEATURES_AUDIT.md
- 已支持tcp / httpcustomDomains、token 认证、多对多、worker 管理
- 未支持stcp/sudp/xtcp/tcpmux、OIDC/TLS 证书、加密/压缩/限速/连接池、LB/健康检查、http 路由高级、插件/虚拟 IP
- 优先级P0 高级传输参数 → P1 域名字段+transport → P2 LB/健康检查 → P3 低频项
- UI 原则:高级字段折叠式 / 按类型动态显示,保画布简洁

View File

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title>
</head>
<body>
<div id="app"></div>

283
web/public/favicon.svg Normal file
View File

@ -0,0 +1,283 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
<defs>
<!-- 主字母像素块(黑色) -->
<rect id="p" width="12" height="12" fill="#000000" />
<!-- frpc 小像素块(蓝色) -->
<rect id="q" width="5" height="5" fill="#2563eb" />
<!-- 装饰小方块(浅灰) -->
<rect id="d" width="8" height="8" fill="#94a3b8" />
<!-- 小十字星装饰 -->
<rect id="s" width="6" height="6" fill="#94a3b8" />
</defs>
<!-- 圆角白色背景(模拟 App 图标外框) -->
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
<!-- ========== 主字母整体居中(横向间距更合理) ========== -->
<g transform="translate(70, 160)">
<!-- ===== W基于原始结构高度约 150px ===== -->
<g transform="translate(0, 0)">
<!-- 左竖线占2列 -->
<use href="#p" x="0" y="0" />
<use href="#p" x="12" y="0" />
<use href="#p" x="0" y="12" />
<use href="#p" x="12" y="12" />
<use href="#p" x="0" y="24" />
<use href="#p" x="12" y="24" />
<use href="#p" x="0" y="36" />
<use href="#p" x="12" y="36" />
<use href="#p" x="0" y="48" />
<use href="#p" x="12" y="48" />
<use href="#p" x="0" y="60" />
<use href="#p" x="12" y="60" />
<use href="#p" x="0" y="72" />
<use href="#p" x="12" y="72" />
<use href="#p" x="0" y="84" />
<use href="#p" x="12" y="84" />
<use href="#p" x="0" y="96" />
<use href="#p" x="12" y="96" />
<use href="#p" x="0" y="108" />
<use href="#p" x="12" y="108" />
<use href="#p" x="0" y="120" />
<use href="#p" x="12" y="120" />
<use href="#p" x="0" y="132" />
<use href="#p" x="12" y="132" />
<!-- 右竖线占2列 -->
<use href="#p" x="84" y="0" />
<use href="#p" x="96" y="0" />
<use href="#p" x="84" y="12" />
<use href="#p" x="96" y="12" />
<use href="#p" x="84" y="24" />
<use href="#p" x="96" y="24" />
<use href="#p" x="84" y="36" />
<use href="#p" x="96" y="36" />
<use href="#p" x="84" y="48" />
<use href="#p" x="96" y="48" />
<use href="#p" x="84" y="60" />
<use href="#p" x="96" y="60" />
<use href="#p" x="84" y="72" />
<use href="#p" x="96" y="72" />
<use href="#p" x="84" y="84" />
<use href="#p" x="96" y="84" />
<use href="#p" x="84" y="96" />
<use href="#p" x="96" y="96" />
<use href="#p" x="84" y="108" />
<use href="#p" x="96" y="108" />
<use href="#p" x="84" y="120" />
<use href="#p" x="96" y="120" />
<use href="#p" x="84" y="132" />
<use href="#p" x="96" y="132" />
<!-- 中间 V 形(从 y=60 开始) -->
<use href="#p" x="24" y="60" />
<use href="#p" x="48" y="60" />
<use href="#p" x="72" y="60" />
<use href="#p" x="24" y="72" />
<use href="#p" x="48" y="72" />
<use href="#p" x="72" y="72" />
<use href="#p" x="24" y="84" />
<use href="#p" x="48" y="84" />
<use href="#p" x="72" y="84" />
<use href="#p" x="24" y="96" />
<use href="#p" x="48" y="96" />
<use href="#p" x="72" y="96" />
<!-- 底部汇合 -->
<use href="#p" x="36" y="108" />
<use href="#p" x="60" y="108" />
<use href="#p" x="36" y="120" />
<use href="#p" x="60" y="120" />
<use href="#p" x="36" y="132" />
<use href="#p" x="60" y="132" />
<use href="#p" x="48" y="132" />
</g>
<!-- ===== U高度与 W 一致,内部空间增大) ===== -->
<g transform="translate(124, 0)">
<!-- 左竖线占2列 -->
<use href="#p" x="0" y="0" />
<use href="#p" x="12" y="0" />
<use href="#p" x="0" y="12" />
<use href="#p" x="12" y="12" />
<use href="#p" x="0" y="24" />
<use href="#p" x="12" y="24" />
<use href="#p" x="0" y="36" />
<use href="#p" x="12" y="36" />
<use href="#p" x="0" y="48" />
<use href="#p" x="12" y="48" />
<use href="#p" x="0" y="60" />
<use href="#p" x="12" y="60" />
<use href="#p" x="0" y="72" />
<use href="#p" x="12" y="72" />
<use href="#p" x="0" y="84" />
<use href="#p" x="12" y="84" />
<use href="#p" x="0" y="96" />
<use href="#p" x="12" y="96" />
<use href="#p" x="0" y="108" />
<use href="#p" x="12" y="108" />
<use href="#p" x="0" y="120" />
<use href="#p" x="12" y="120" />
<use href="#p" x="0" y="132" />
<use href="#p" x="12" y="132" />
<!-- 右竖线占2列 -->
<use href="#p" x="84" y="0" />
<use href="#p" x="96" y="0" />
<use href="#p" x="84" y="12" />
<use href="#p" x="96" y="12" />
<use href="#p" x="84" y="24" />
<use href="#p" x="96" y="24" />
<use href="#p" x="84" y="36" />
<use href="#p" x="96" y="36" />
<use href="#p" x="84" y="48" />
<use href="#p" x="96" y="48" />
<use href="#p" x="84" y="60" />
<use href="#p" x="96" y="60" />
<use href="#p" x="84" y="72" />
<use href="#p" x="96" y="72" />
<use href="#p" x="84" y="84" />
<use href="#p" x="96" y="84" />
<use href="#p" x="84" y="96" />
<use href="#p" x="96" y="96" />
<use href="#p" x="84" y="108" />
<use href="#p" x="96" y="108" />
<use href="#p" x="84" y="120" />
<use href="#p" x="96" y="120" />
<use href="#p" x="84" y="132" />
<use href="#p" x="96" y="132" />
<!-- 底部横线 -->
<use href="#p" x="0" y="132" />
<use href="#p" x="12" y="132" />
<use href="#p" x="24" y="132" />
<use href="#p" x="36" y="132" />
<use href="#p" x="48" y="132" />
<use href="#p" x="60" y="132" />
<use href="#p" x="72" y="132" />
<use href="#p" x="84" y="132" />
<use href="#p" x="96" y="132" />
<!-- ===== U 内部:竖向排列的 frpc蓝色5×5 像素) ===== -->
<g transform="translate(32, 18)">
<!-- f -->
<use href="#q" x="0" y="0" />
<use href="#q" x="0" y="6" />
<use href="#q" x="0" y="12" />
<use href="#q" x="0" y="18" />
<use href="#q" x="6" y="0" />
<use href="#q" x="12" y="0" />
<use href="#q" x="18" y="0" />
<use href="#q" x="6" y="12" />
<use href="#q" x="12" y="12" />
<!-- r -->
<use href="#q" x="0" y="28" />
<use href="#q" x="0" y="34" />
<use href="#q" x="0" y="40" />
<use href="#q" x="0" y="46" />
<use href="#q" x="6" y="28" />
<use href="#q" x="12" y="28" />
<use href="#q" x="18" y="28" />
<use href="#q" x="6" y="40" />
<use href="#q" x="12" y="40" />
<!-- p -->
<use href="#q" x="0" y="56" />
<use href="#q" x="0" y="62" />
<use href="#q" x="0" y="68" />
<use href="#q" x="0" y="74" />
<use href="#q" x="0" y="80" />
<use href="#q" x="6" y="56" />
<use href="#q" x="12" y="56" />
<use href="#q" x="18" y="56" />
<use href="#q" x="18" y="62" />
<use href="#q" x="18" y="68" />
<use href="#q" x="6" y="74" />
<use href="#q" x="12" y="74" />
<use href="#q" x="18" y="74" />
<!-- c -->
<use href="#q" x="6" y="90" />
<use href="#q" x="12" y="90" />
<use href="#q" x="18" y="90" />
<use href="#q" x="0" y="96" />
<use href="#q" x="0" y="102" />
<use href="#q" x="6" y="108" />
<use href="#q" x="12" y="108" />
<use href="#q" x="18" y="108" />
</g>
</g>
<!-- ===== I中间竖线 2 列宽,高度一致) ===== -->
<g transform="translate(248, 0)">
<!-- 顶部横线占6列 -->
<use href="#p" x="0" y="0" />
<use href="#p" x="12" y="0" />
<use href="#p" x="24" y="0" />
<use href="#p" x="36" y="0" />
<use href="#p" x="48" y="0" />
<use href="#p" x="60" y="0" />
<!-- 中间竖线2 列宽) -->
<use href="#p" x="24" y="12" />
<use href="#p" x="36" y="12" />
<use href="#p" x="24" y="24" />
<use href="#p" x="36" y="24" />
<use href="#p" x="24" y="36" />
<use href="#p" x="36" y="36" />
<use href="#p" x="24" y="48" />
<use href="#p" x="36" y="48" />
<use href="#p" x="24" y="60" />
<use href="#p" x="36" y="60" />
<use href="#p" x="24" y="72" />
<use href="#p" x="36" y="72" />
<use href="#p" x="24" y="84" />
<use href="#p" x="36" y="84" />
<use href="#p" x="24" y="96" />
<use href="#p" x="36" y="96" />
<use href="#p" x="24" y="108" />
<use href="#p" x="36" y="108" />
<use href="#p" x="24" y="120" />
<use href="#p" x="36" y="120" />
<!-- 底部横线占6列 -->
<use href="#p" x="0" y="132" />
<use href="#p" x="12" y="132" />
<use href="#p" x="24" y="132" />
<use href="#p" x="36" y="132" />
<use href="#p" x="48" y="132" />
<use href="#p" x="60" y="132" />
</g>
</g>
<!-- ========== 外围装饰元素(像素风,增加层次) ========== -->
<!-- 左上角小十字星 -->
<g transform="translate(40, 40)">
<use href="#s" x="0" y="0" />
<use href="#s" x="0" y="12" />
<use href="#s" x="6" y="6" />
<use href="#s" x="12" y="0" />
<use href="#s" x="12" y="12" />
</g>
<!-- 右上角小方块 -->
<use href="#d" x="440" y="40" />
<!-- 左下角小方块 -->
<use href="#d" x="40" y="440" />
<!-- 右下角小十字星 -->
<g transform="translate(436, 436)">
<use href="#s" x="0" y="0" />
<use href="#s" x="0" y="12" />
<use href="#s" x="6" y="6" />
<use href="#s" x="12" y="0" />
<use href="#s" x="12" y="12" />
</g>
<!-- 三个随机黑色装饰点更大一点12×12 -->
<rect x="90" y="380" width="12" height="12" fill="#000000" />
<rect x="420" y="140" width="12" height="12" fill="#000000" />
<rect x="240" y="60" width="12" height="12" fill="#000000" />
<!-- 额外小点缀:蓝色小点,呼应 frpc 颜色 -->
<rect x="180" y="400" width="6" height="6" fill="#2563eb" />
<rect x="370" y="80" width="6" height="6" fill="#2563eb" />
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -13,7 +13,7 @@
<div class="app-shell">
<aside class="sidebar">
<div class="sb-brand">
<span class="sb-logo" aria-hidden="true">W</span>
<img class="sb-logo" src="/favicon.svg" alt="webui4frpc" />
<span class="sb-brand-text">
<b>webui4frpc</b>
<small>令牌环 · 转发编排</small>
@ -34,7 +34,15 @@
</nav>
<div class="sb-foot">
<span class="sb-status"><span class="dot" /> token-ring · M6</span>
<div class="sb-identity" v-if="authReady">
<span class="id-name">{{ authName || '匿名' }}</span>
<span class="id-level" :class="authLevel">{{ levelLabel }}</span>
</div>
<div class="sb-theme" role="group" aria-label="主题">
<button class="sw white" :class="{ on: theme === 'white' }" title="白色" @click="setTheme('white')" />
<button class="sw blue" :class="{ on: theme === 'blue' }" title="蓝色" @click="setTheme('blue')" />
<button class="sw pink" :class="{ on: theme === 'pink' }" title="粉色" @click="setTheme('pink')" />
</div>
</div>
</aside>
@ -46,6 +54,7 @@
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<ClusterView v-else-if="view === 'cluster'" />
<UsersView v-else-if="view === 'users'" />
<StatusView v-else />
</main>
</div>
@ -53,23 +62,54 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import CanvasView from './views/CanvasView.vue'
import SettingsView from './views/SettingsView.vue'
import StatusView from './views/StatusView.vue'
import ClusterView from './views/ClusterView.vue'
import UsersView from './views/UsersView.vue'
import { authLevel, authName, authReady, fetchMe, isAdmin } from './auth'
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster'
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster' | 'users'
const view = ref<ViewKey>('status')
const nav: { key: ViewKey; label: string; icon: string }[] = [
onMounted(() => { fetchMe() })
const levelLabel = computed(() =>
authLevel.value === 'admin' ? '管理员' : authLevel.value === 'write' ? '写' : '只读',
)
// ---- theme switcher (white default / blue / pink) ----
// The data-theme attribute is applied pre-mount by main.ts (no flash); here we
// only mirror it so the active swatch highlights, and update it on click.
type Theme = 'white' | 'blue' | 'pink'
const VALID_THEMES: Theme[] = ['white', 'blue', 'pink']
const theme = ref<Theme>(
(VALID_THEMES as string[]).includes(document.documentElement.getAttribute('data-theme') || '')
? (document.documentElement.getAttribute('data-theme') as Theme)
: 'white',
)
const setTheme = (t: Theme) => {
theme.value = t
document.documentElement.setAttribute('data-theme', t)
localStorage.setItem('w4f-theme', t)
}
const allNav: { key: ViewKey; label: string; icon: string; adminOnly?: boolean }[] = [
{ key: 'status', label: '状态', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>' },
{ key: 'canvas', label: '连接配置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
{ key: 'settings', label: '设置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
{ key: 'cluster', label: '集群', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4m0 0-5 6m5-6 5 6"/></svg>' },
{ key: 'users', label: '账号与密钥', adminOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
]
const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label ?? '')
// nav filters admin-only entries (账号与密钥) until /me resolves to admin.
// Until authReady, the users entry is hidden so a viewer never sees it flash.
const nav = computed(() =>
allNav.filter((n) => !n.adminOnly || (authReady.value && isAdmin.value)),
)
const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?.label ?? '')
</script>
<style scoped>
@ -111,14 +151,8 @@ const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label
height: 40px;
border-radius: 13px;
flex: 0 0 auto;
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 19px;
letter-spacing: 0.5px;
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
box-shadow: 0 6px 18px rgba(255, 127, 172, 0.45);
object-fit: contain;
box-shadow: 0 6px 18px var(--w4f-glow);
}
.sb-brand-text b {
font-size: 16.5px;
@ -182,26 +216,61 @@ const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label
margin-top: auto;
padding-top: 16px;
}
.sb-status {
display: inline-flex;
/* ---------- identity chip ---------- */
.sb-identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 13px;
border-radius: var(--w4f-radius-pill);
padding: 8px 10px;
margin-bottom: 12px;
border-radius: 10px;
background: var(--w4f-card-2);
border: 1px solid var(--w4f-line);
font-size: 12.5px;
}
.sb-identity .id-name {
font-weight: 600;
color: var(--w4f-primary-h);
background: var(--w4f-primary-50);
color: var(--w4f-fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-status .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--w4f-primary);
box-shadow: 0 0 0 3px rgba(255, 127, 172, 0.18);
animation: blink 1.6s infinite;
.sb-identity .id-level {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
color: var(--w4f-muted);
background: var(--w4f-card);
}
@keyframes blink { 50% { opacity: 0.25; } }
.sb-identity .id-level.admin {
color: #fff;
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
}
.sb-identity .id-level.write {
color: var(--w4f-warning);
background: var(--w4f-warning-50);
}
.sb-identity .id-level.read {
color: var(--w4f-muted);
}
/* ---------- theme switcher ---------- */
.sb-theme { display: flex; gap: 6px; margin-bottom: 12px; }
.sb-theme .sw {
width: 26px; height: 26px; border-radius: 8px; border: 2px solid var(--w4f-line);
cursor: pointer; padding: 0;
transition: transform 0.15s var(--w4f-ease-spring), box-shadow 0.15s var(--w4f-ease-spring);
}
.sb-theme .sw:hover { transform: translateY(-1px); }
.sb-theme .sw.on { border-color: var(--w4f-primary); box-shadow: 0 0 0 3px var(--w4f-glow-soft); }
/* swatch fills are FIXED (represent each theme's identity), not theme-driven */
.sb-theme .sw.white { background: linear-gradient(135deg, #ffffff, #cdd4e0); }
.sb-theme .sw.blue { background: linear-gradient(135deg, #3b82f6, #06b6d4); }
.sb-theme .sw.pink { background: linear-gradient(135deg, #ff7fac, #f33b7c); }
/* ---------- main ---------- */
.main {

View File

@ -1,14 +1,20 @@
// HTTP client and API functions for webui4frpc.
import type {
ApiKey,
ApiKeyCreated,
BinaryStatus,
CacheResp,
CanvasData,
CanvasExportEnvelope,
ClusterNodesResp,
InstallResult,
MeResp,
Remote,
RingSnapshot,
Settings,
StatusResp,
User,
WorkerLogBundle,
} from "./types";
class HTTPError extends Error {
@ -66,6 +72,49 @@ export const api = {
method: "DELETE",
}),
// Per-forward start/stop (forwards page). local-only forwards toggle the
// local frpc worker; cluster forwards submit/revoke via the ring.
forwardStart: (local: string, remote: string, remotePort: number) =>
request<{ ok: boolean }>("/api/manager/forwards/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ local, remote, remotePort }),
}),
forwardStop: (local: string, remote: string, remotePort: number) =>
request<{ ok: boolean }>("/api/manager/forwards/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ local, remote, remotePort }),
}),
groupStart: (group: string) =>
request<{ ok: boolean }>("/api/manager/forwards/group/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ group }),
}),
groupStop: (group: string) =>
request<{ ok: boolean }>("/api/manager/forwards/group/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ group }),
}),
// assignGroup changes a single forward's group label (status page chip).
// Empty group clears the assignment (移出分组). Pure DB update, no worker.
assignGroup: (local: string, remote: string, remotePort: number, group: string) =>
request<{ ok: boolean }>("/api/manager/forwards/assign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ local, remote, remotePort, group }),
}),
// deleteGroup dissolves a group: all members moved to 未分组. The group is
// just a label on links, so clearing all members is the complete delete.
deleteGroup: (group: string) =>
request<{ ok: boolean }>("/api/manager/forwards/group/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ group }),
}),
profileStart: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
method: "POST",
@ -101,11 +150,12 @@ export const api = {
clusterCreate: () =>
request<RingSnapshot>("/api/manager/cluster/create", { method: "POST" }),
// clusterJoinRing: THIS node joins the cluster at peer addr (加入集群).
clusterJoinRing: (addr: string) =>
// joinKey is the sponsor node's nodeKey — required for admission security.
clusterJoinRing: (addr: string, joinKey: string) =>
request<RingSnapshot>("/api/manager/cluster/join-ring", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ addr }),
body: JSON.stringify({ addr, joinKey }),
}),
// clusterRemoveNode: publish a node-removal command (移除节点 / 退出集群[self]).
clusterRemoveNode: (id: string) =>
@ -114,4 +164,45 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
}),
// M7 auth/accounts/API keys/canvas export-import/worker logs.
me: () => request<MeResp>("/api/manager/me"),
listUsers: () => request<{ users: User[] }>("/api/manager/users"),
createUser: (username: string, password: string, role: 'admin' | 'viewer') =>
request<User>("/api/manager/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, role }),
}),
updateUser: (name: string, patch: { password?: string; role?: 'admin' | 'viewer'; enabled?: boolean }) =>
request<User>(`/api/manager/users/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
}),
deleteUser: (name: string) =>
request<void>(`/api/manager/users/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
listApiKeys: () => request<{ apiKeys: ApiKey[] }>("/api/manager/apikeys"),
createApiKey: (userId: number, label: string, scope: 'read' | 'write' | 'admin') =>
request<ApiKeyCreated>("/api/manager/apikeys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, label, scope }),
}),
deleteApiKey: (id: number) =>
request<void>(`/api/manager/apikeys/${id}`, { method: "DELETE" }),
// Canvas export/import (转发表 备份/还原).
exportCanvas: () => request<CanvasExportEnvelope>("/api/manager/canvas/export"),
importCanvas: (data: CanvasData | CanvasExportEnvelope) =>
request<CanvasData>("/api/manager/canvas/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
// Worker-log bundle (HTTP fan-out across ring nodes; read-level/auditor).
exportWorkerLogs: () => request<WorkerLogBundle>("/api/manager/cluster/logs/export"),
};

34
web/src/auth.ts Normal file
View File

@ -0,0 +1,34 @@
// Lightweight reactive auth principal for the SPA. The browser handles Basic
// Auth (prompted once when the SPA shell loads behind auth("read")); this just
// mirrors the resolved identity from GET /me so views can gate their UI.
//
// We default to read until /me resolves so a viewer never sees a flash of
// admin controls. canWrite/isAdmin are memoized over the reactive level.
import { computed, ref } from 'vue'
import type { AccessLevel } from './types'
import { api } from './api'
export const authLevel = ref<AccessLevel>('read')
export const authName = ref('')
export const authReady = ref(false)
const RANK: Record<AccessLevel, number> = { read: 1, write: 2, admin: 3 }
export const canWrite = computed(() => RANK[authLevel.value] >= RANK.write)
export const isAdmin = computed(() => authLevel.value === 'admin')
// fetchMe resolves the current principal. Called once on app mount; safe to
// call again after account changes that could affect the live session.
export async function fetchMe(): Promise<void> {
try {
const me = await api.me()
authLevel.value = me.level
authName.value = me.name
} catch {
// 401 (no/failed auth) — stay read-only; the browser will have prompted.
authLevel.value = 'read'
authName.value = ''
} finally {
authReady.value = true
}
}

View File

@ -197,19 +197,19 @@ const httpHeadersField = computed({
.local-node {
min-width: 240px;
border-radius: 14px 14px 14px 4px;
background: #e8f5e9;
border: 2px solid #4caf50;
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.18);
background: var(--w4f-ok-50);
border: 2px solid var(--w4f-ok);
box-shadow: 0 2px 8px var(--w4f-ok-glow);
padding: 8px 10px;
&.selected {
border-color: #ffd54f;
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
border-color: var(--w4f-warning);
box-shadow: 0 0 0 3px var(--w4f-warn-glow);
}
&.conflicted {
border-color: #f5222d;
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
border-color: var(--w4f-danger);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--w4f-danger) 40%, transparent);
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@ -217,10 +217,10 @@ const httpHeadersField = computed({
@keyframes conflict-pulse {
0%,
100% {
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--w4f-danger) 40%, transparent);
}
50% {
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
box-shadow: 0 0 0 5px color-mix(in srgb, var(--w4f-danger) 70%, transparent);
}
}
@ -230,7 +230,7 @@ const httpHeadersField = computed({
gap: 6px;
.node-icon {
color: #4caf50;
color: var(--w4f-ok);
font-size: 16px;
}
}
@ -242,21 +242,21 @@ const httpHeadersField = computed({
background: transparent;
font-weight: 600;
font-size: 14px;
color: #2e7d32;
color: var(--w4f-ok-text);
&:focus {
outline: 1px solid #4caf50;
outline: 1px solid var(--w4f-ok);
}
}
.del-btn {
border: none;
background: transparent;
color: #81c784;
color: var(--w4f-faint);
font-size: 16px;
cursor: pointer;
line-height: 1;
&:hover {
color: #c62828;
color: var(--w4f-danger);
}
}
@ -264,15 +264,15 @@ const httpHeadersField = computed({
%field-input {
flex: 1;
min-width: 0;
border: 1px solid #a5d6a7;
border: 1px solid var(--w4f-ok-border);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
background: var(--w4f-card-solid);
color: var(--w4f-fg);
&:focus {
outline: none;
border-color: #4caf50;
border-color: var(--w4f-ok);
}
}
@ -288,7 +288,7 @@ const httpHeadersField = computed({
align-items: center;
gap: 4px;
font-size: 11px;
color: #558b2f;
color: var(--w4f-ok-text);
input,
select {
@ -309,9 +309,9 @@ const httpHeadersField = computed({
margin-top: 6px;
.adv-toggle {
border: 1px dashed #a5d6a7;
border: 1px dashed var(--w4f-ok-border);
background: transparent;
color: #558b2f;
color: var(--w4f-ok-text);
font-size: 11px;
border-radius: 6px;
padding: 2px 8px;
@ -327,7 +327,7 @@ const httpHeadersField = computed({
gap: 6px;
margin-top: 6px;
padding: 8px;
background: rgba(76, 175, 80, 0.06);
background: var(--w4f-ok-100);
border-radius: 8px;
label {
@ -335,7 +335,7 @@ const httpHeadersField = computed({
flex-direction: column;
gap: 2px;
font-size: 11px;
color: #558b2f;
color: var(--w4f-ok-text);
input,
select {
@ -359,8 +359,8 @@ const httpHeadersField = computed({
margin: 4px 0 2px;
font-size: 11px;
font-weight: 600;
color: #2e7d32;
border-top: 1px dashed rgba(76, 175, 80, 0.35);
color: var(--w4f-ok-text);
border-top: 1px dashed var(--w4f-ok-border);
padding-top: 4px;
}
}

View File

@ -5,7 +5,7 @@
class="port-edge-path"
:class="{ selected, conflicted }"
fill="none"
:stroke="strokeColor"
:style="{ stroke: strokeColor }"
:stroke-width="selected ? 3.5 : 2.5"
/>
<!-- Draggable port label. Dragging it moves the curve (and its midpoint)
@ -21,7 +21,19 @@
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
{{ displayPort }}
<span
class="port-toggle"
:class="{ off: isDisabled }"
@pointerdown.stop
@click.stop="emit('toggle-disabled', { edgeId: props.id })"
>{{ isDisabled ? '禁' : '通' }}</span>
<span class="port-num">{{ displayPort }}</span>
<span
v-if="groupName"
class="port-group"
@pointerdown.stop
@click.stop="emit('edit-group', { edgeId: props.id })"
>{{ groupName }}</span>
</div>
</EdgeLabelRenderer>
</g>
@ -46,6 +58,8 @@ const emit = defineEmits<{
e: 'label-drag',
payload: { edgeId: string; offset: { x: number; y: number } },
): void
(e: 'toggle-disabled', payload: { edgeId: string }): void
(e: 'edit-group', payload: { edgeId: string }): void
}>()
// User-dragged offset relative to the curve midpoint, initialized from the
@ -141,6 +155,11 @@ function readZoom(): number {
// above earlier ones.
const layer = computed(() => Number(props.data?.layer ?? 0))
// isDisabled / groupName: read from edge data so the toggle badge and group
// label reflect the persisted state (disabled/group survive canvas saves).
const isDisabled = computed(() => Boolean(props.data?.disabled))
const groupName = computed(() => String(props.data?.group ?? ''))
const displayPort = computed(() => {
const t = String(props.label ?? '')
return t || '?'
@ -186,19 +205,24 @@ const labelStyle = computed(() => ({
}))
const strokeColor = computed(() => {
if (isDisabled.value) {
return 'var(--el-color-info-light-5)'
}
if (props.conflicted) {
return '#f5222d'
return 'var(--w4f-danger)'
}
const l = layer.value
// Muted, UI-friendly palette that matches the existing theme (Element Plus
// primary / success / warning / danger / info), cycled per layer.
// Theme-aware palette: cycle the Element Plus semantic colors per layer so
// parallel edges stay distinguishable while following the active accent.
// Returned as CSS var() strings — applied via inline :style (presentation
// attributes don't resolve var()), so the edge recolors live on theme switch.
const hues = [
'#409eff',
'#67c23a',
'#e6a23c',
'#f56c6c',
'#909399',
'#409eff',
'var(--el-color-primary)',
'var(--el-color-success)',
'var(--el-color-warning)',
'var(--el-color-danger)',
'var(--el-color-info)',
'var(--el-color-primary)',
]
return hues[l % hues.length]
})
@ -288,10 +312,10 @@ function midpointOf(path: string): { x: number; y: number } {
<style scoped lang="scss">
.port-edge-path {
&.selected {
filter: drop-shadow(0 0 4px rgba(244, 67, 54, 0.6));
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--w4f-primary) 60%, transparent));
}
&.conflicted {
filter: drop-shadow(0 0 6px rgba(245, 34, 45, 0.8));
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--w4f-danger) 80%, transparent));
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@ -307,7 +331,7 @@ function midpointOf(path: string): { x: number; y: number } {
}
.port-label {
background: rgba(255, 255, 255, 0.92);
background: color-mix(in srgb, var(--w4f-card-solid) 90%, transparent);
color: $color-text-primary;
border: 1px solid $color-border-light;
border-radius: 10px;
@ -316,7 +340,7 @@ function midpointOf(path: string): { x: number; y: number } {
font-weight: 600;
font-family: inherit;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
box-shadow: $shadow-sm;
user-select: none;
transition:
box-shadow $transition-fast,
@ -324,7 +348,40 @@ function midpointOf(path: string): { x: number; y: number } {
&.dragging {
cursor: grabbing;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
box-shadow: $shadow-md;
}
.port-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
border-radius: 6px;
padding: 0 4px;
margin-right: 6px;
font-size: 10px;
font-weight: 700;
background: color-mix(in srgb, var(--el-color-success) 18%, transparent);
color: var(--el-color-success);
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&.off {
background: color-mix(in srgb, var(--el-color-info) 18%, transparent);
color: var(--el-color-info);
}
}
.port-group {
margin-left: 6px;
padding: 1px 6px;
border-radius: 6px;
font-size: 10px;
font-weight: 600;
background: color-mix(in srgb, var(--w4f-secondary) 16%, transparent);
color: var(--w4f-secondary-h);
cursor: pointer;
transition: background $transition-fast;
}
}
</style>

View File

@ -107,19 +107,19 @@ const showAdv = ref(false)
.remote-node {
min-width: 200px;
border-radius: 14px 14px 4px 14px;
background: #fff3e0;
border: 2px solid #ff9800;
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.18);
background: var(--w4f-warn-50);
border: 2px solid var(--w4f-warning);
box-shadow: 0 2px 8px var(--w4f-warn-glow);
padding: 8px 10px;
&.selected {
border-color: #ffd54f;
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
border-color: var(--w4f-warning);
box-shadow: 0 0 0 3px var(--w4f-warn-glow);
}
&.conflicted {
border-color: #f5222d;
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
border-color: var(--w4f-danger);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--w4f-danger) 40%, transparent);
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@ -127,10 +127,10 @@ const showAdv = ref(false)
@keyframes conflict-pulse {
0%,
100% {
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--w4f-danger) 40%, transparent);
}
50% {
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
box-shadow: 0 0 0 5px color-mix(in srgb, var(--w4f-danger) 70%, transparent);
}
}
@ -140,7 +140,7 @@ const showAdv = ref(false)
gap: 6px;
.node-icon {
color: #ff9800;
color: var(--w4f-warning);
font-size: 16px;
}
}
@ -152,9 +152,9 @@ const showAdv = ref(false)
background: transparent;
font-weight: 600;
font-size: 14px;
color: #e65100;
color: var(--w4f-warn-text);
&:focus {
outline: 1px solid #ff9800;
outline: 1px solid var(--w4f-warning);
}
}
@ -163,18 +163,18 @@ const showAdv = ref(false)
align-items: center;
gap: 2px;
font-size: 10px;
color: #ef6c00;
color: var(--w4f-warn-text);
}
.del-btn {
border: none;
background: transparent;
color: #ffb74d;
color: var(--w4f-faint);
font-size: 16px;
cursor: pointer;
line-height: 1;
&:hover {
color: #c62828;
color: var(--w4f-danger);
}
}
@ -189,20 +189,20 @@ const showAdv = ref(false)
align-items: center;
gap: 4px;
font-size: 11px;
color: #ef6c00;
color: var(--w4f-warn-text);
input {
flex: 1;
min-width: 0;
border: 1px solid #ffcc80;
border: 1px solid var(--w4f-warn-border);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
background: var(--w4f-card-solid);
color: var(--w4f-fg);
&:focus {
outline: none;
border-color: #ff9800;
border-color: var(--w4f-warning);
}
}
@ -215,9 +215,9 @@ const showAdv = ref(false)
.adv {
margin-top: 6px;
.adv-toggle {
border: 1px dashed #ffcc80;
border: 1px dashed var(--w4f-warn-border);
background: transparent;
color: #ef6c00;
color: var(--w4f-warn-text);
font-size: 11px;
border-radius: 6px;
padding: 2px 8px;
@ -229,7 +229,7 @@ const showAdv = ref(false)
gap: 4px;
margin-top: 6px;
padding: 6px;
background: rgba(255, 152, 0, 0.06);
background: var(--w4f-warn-100);
border-radius: 8px;
label {
@ -237,21 +237,21 @@ const showAdv = ref(false)
align-items: center;
gap: 4px;
font-size: 11px;
color: #ef6c00;
color: var(--w4f-warn-text);
input,
select {
flex: 1;
min-width: 0;
border: 1px solid #ffcc80;
border: 1px solid var(--w4f-warn-border);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
background: var(--w4f-card-solid);
color: var(--w4f-fg);
&:focus {
outline: none;
border-color: #ff9800;
border-color: var(--w4f-warning);
}
}
}

View File

@ -7,6 +7,20 @@ import 'element-plus/dist/index.css'
import './styles/theme.css'
import App from './App.vue'
// Apply the saved theme BEFORE mount so the first paint already uses the
// correct palette (no white→pink/blue flash). :root is the white default, so
// only blue/pink need an explicit data-theme attribute.
(function applyThemeEarly() {
try {
const saved = localStorage.getItem('w4f-theme')
if (saved === 'blue' || saved === 'pink') {
document.documentElement.setAttribute('data-theme', saved)
}
} catch {
/* localStorage unavailable (private mode) — stay on the white default */
}
})()
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)

View File

@ -1,19 +1,190 @@
/*
* webui4frpc global theme — sakura × frost × glassmorphism.
* Faithfully follows the ModelRouter WebUI design vocabulary: a soft animated
* gradient-mesh background with drifting blurred blobs, translucent glass cards
* with a spring entrance, KPI stat cards, gradient primary buttons, pill tags,
* slim themed scrollbars. All theming is plain CSS custom properties (no
* preprocessor needed at runtime); Element Plus variables are overridden so EP
* components inherit the same sakura look.
* webui4frpc global theme — glassmorphism with a switchable accent.
*
* Semantic note: webui4frpc keeps green = "online / ok" — exactly as
* ModelRouter does (--ok). Sakura (#FF7FAC) is the PRIMARY accent, frost
* (#88C0D0) the secondary; online/healthy status stays green.
* Three themes share the same surface treatment (translucent glass cards,
* animated gradient-mesh background, KPI cards, gradient buttons, pill tags,
* slim scrollbars); only the accent hue changes:
* :root = white (neutral slate) — the DEFAULT
* [data-theme="blue"] = blue / cyan
* [data-theme="pink"] = sakura / frost (the original ModelRouter palette)
* The active theme is applied by setting data-theme on <html> (see App.vue +
* main.ts; persisted in localStorage). Element Plus variables are overridden
* per theme so EP components inherit the active accent live.
*
* Semantic note: green = "online / ok" in EVERY theme (as in ModelRouter).
*/
:root {
/* ---- palette (sakura / frost) ---- */
/* ---- palette (white / neutral slate — default) ---- */
--w4f-primary: #5b6478;
--w4f-primary-h: #3f4654;
--w4f-primary-50: #f1f3f7;
--w4f-primary-100: #e3e7ef;
--w4f-primary-200: #cdd4e0;
--w4f-primary-300: #a7b1c3;
--w4f-secondary: #8a93a8;
--w4f-secondary-h: #5b6478;
--w4f-secondary-50: #f4f6f9;
--w4f-secondary-200: #c8ced9;
--w4f-danger: #d6495f;
--w4f-danger-50: #fdecef;
--w4f-warning: #b7791f;
--w4f-warning-50: #fdf3e3;
--w4f-info: #3f6ef5;
--w4f-ok: #17a964;
/* ---- surfaces / text ---- */
--w4f-bg-1: #f4f5f7;
--w4f-bg-2: #ffffff;
--w4f-bg-3: #eef0f3;
--w4f-bg-soft: #f8f9fb;
--w4f-bg-muted: #f4f5f7;
--w4f-bg-hover: #eef0f3;
--w4f-bg-active: #e3e7ef;
--w4f-fg: #2b2f3a;
--w4f-fg-2: #5a6072;
--w4f-muted: #6b7280;
--w4f-faint: #9ca3af;
--w4f-card: rgba(255, 255, 255, 0.66);
--w4f-card-solid: #ffffff;
--w4f-card-2: rgba(255, 255, 255, 0.46);
--w4f-line: rgba(91, 100, 120, 0.16);
--w4f-line-2: rgba(91, 100, 120, 0.10);
--w4f-line-3: rgba(91, 100, 120, 0.06);
--w4f-line-strong: rgba(60, 70, 90, 0.22);
/* ---- background blobs ---- */
--w4f-blob1: rgba(160, 170, 190, 0.30);
--w4f-blob2: rgba(200, 210, 225, 0.28);
--w4f-blob3: rgba(150, 160, 180, 0.22);
/* ---- shadows / radii / motion (theme-independent) ---- */
--w4f-sh-sm: 0 1px 2px rgba(40, 50, 70, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
--w4f-sh-md: 0 6px 20px rgba(40, 50, 70, 0.12);
--w4f-sh-lg: 0 18px 46px rgba(40, 50, 70, 0.20);
--w4f-glass: 18px;
--w4f-radius: 16px;
--w4f-radius-sm: 12px;
--w4f-radius-xs: 10px;
--w4f-radius-pill: 99px;
--w4f-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--w4f-ease: cubic-bezier(0.4, 0, 0.2, 1);
--w4f-font: 'Quicksand', 'Nunito', 'Inter', 'Noto Sans SC', ui-sans-serif,
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'PingFang SC', 'Microsoft YaHei', sans-serif;
--w4f-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
/* ---- accent-derived glow / chrome (theme-specific) ---- */
--w4f-glow: rgba(91, 100, 120, 0.30);
--w4f-danger-glow: rgba(214, 73, 95, 0.30);
--w4f-glow-soft: rgba(91, 100, 120, 0.18);
--w4f-thumb: rgba(120, 130, 150, 0.40);
--w4f-thumb-hover: rgba(91, 100, 120, 0.60);
--w4f-selection: #cdd4e0;
/* ---- Element Plus variable overrides (concrete hex — EP needs real
* colors for its color-mix / opacity helpers, not var() references). ---- */
--el-color-primary: #5b6478;
--el-color-primary-light-3: #a7b1c3;
--el-color-primary-light-5: #b9c1cf;
--el-color-primary-light-7: #cdd4e0;
--el-color-primary-light-8: #e3e7ef;
--el-color-primary-light-9: #f1f3f7;
--el-color-primary-dark-2: #3f4654;
--el-color-success: #17a964;
--el-color-warning: #b7791f;
--el-color-danger: #d6495f;
--el-color-info: #6b7280;
--el-border-radius-base: 12px;
--el-border-radius-small: 8px;
--el-font-family: var(--w4f-font);
/* ---- semantic tint sets (ok/warning are constant across all themes —
* defined once in :root so canvas node cards stay semantic-green/amber in
* every theme while their SURFACES follow the active theme's card/fg).
* color-mix blends the semantic hue into the live surface/text so tints
* adapt when the theme changes the underlying --w4f-card-solid / --w4f-fg. */
--w4f-ok-50: color-mix(in srgb, var(--w4f-ok) 7%, var(--w4f-card-solid));
--w4f-ok-100: color-mix(in srgb, var(--w4f-ok) 13%, var(--w4f-card-solid));
--w4f-ok-border: color-mix(in srgb, var(--w4f-ok) 40%, transparent);
--w4f-ok-glow: color-mix(in srgb, var(--w4f-ok) 20%, transparent);
--w4f-ok-text: color-mix(in srgb, var(--w4f-ok) 48%, var(--w4f-fg));
--w4f-warn-50: color-mix(in srgb, var(--w4f-warning) 9%, var(--w4f-card-solid));
--w4f-warn-100: color-mix(in srgb, var(--w4f-warning) 15%, var(--w4f-card-solid));
--w4f-warn-border: color-mix(in srgb, var(--w4f-warning) 42%, transparent);
--w4f-warn-glow: color-mix(in srgb, var(--w4f-warning) 20%, transparent);
--w4f-warn-text: color-mix(in srgb, var(--w4f-warning) 52%, var(--w4f-fg));
}
/* ---- blue theme ---- */
[data-theme='blue'] {
--w4f-primary: #3b82f6;
--w4f-primary-h: #2563eb;
--w4f-primary-50: #eff6ff;
--w4f-primary-100: #dbeafe;
--w4f-primary-200: #bfdbfe;
--w4f-primary-300: #93c5fd;
--w4f-secondary: #06b6d4;
--w4f-secondary-h: #0891b2;
--w4f-secondary-50: #ecfeff;
--w4f-secondary-200: #a5f3fc;
--w4f-danger: #ef4444;
--w4f-danger-50: #fee2e2;
--w4f-warning: #b7791f;
--w4f-warning-50: #fdf3e3;
--w4f-info: #3f6ef5;
--w4f-ok: #17a964;
--w4f-bg-1: #eff6ff;
--w4f-bg-2: #ffffff;
--w4f-bg-3: #dbeafe;
--w4f-bg-soft: #f5f9ff;
--w4f-bg-muted: #eff6ff;
--w4f-bg-hover: #e0ebfd;
--w4f-bg-active: #dbeafe;
--w4f-fg: #1e293b;
--w4f-fg-2: #475569;
--w4f-muted: #64748b;
--w4f-faint: #94a3b8;
--w4f-card: rgba(255, 255, 255, 0.66);
--w4f-card-solid: #ffffff;
--w4f-card-2: rgba(255, 255, 255, 0.46);
--w4f-line: rgba(59, 130, 246, 0.16);
--w4f-line-2: rgba(59, 130, 246, 0.10);
--w4f-line-3: rgba(59, 130, 246, 0.06);
--w4f-line-strong: rgba(30, 64, 175, 0.22);
--w4f-blob1: rgba(59, 130, 246, 0.40);
--w4f-blob2: rgba(6, 182, 212, 0.36);
--w4f-blob3: rgba(99, 102, 241, 0.26);
--w4f-sh-sm: 0 1px 2px rgba(30, 58, 138, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
--w4f-sh-md: 0 6px 20px rgba(30, 58, 138, 0.13);
--w4f-sh-lg: 0 18px 46px rgba(30, 58, 138, 0.22);
--w4f-glow: rgba(59, 130, 246, 0.32);
--w4f-danger-glow: rgba(239, 68, 68, 0.30);
--w4f-glow-soft: rgba(59, 130, 246, 0.18);
--w4f-thumb: rgba(59, 130, 246, 0.40);
--w4f-thumb-hover: rgba(37, 99, 235, 0.60);
--w4f-selection: #bfdbfe;
--el-color-primary: #3b82f6;
--el-color-primary-light-3: #93c5fd;
--el-color-primary-light-5: #a5c8fb;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #d6e8fd;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #2563eb;
--el-color-success: #17a964;
--el-color-warning: #b7791f;
--el-color-danger: #ef4444;
--el-color-info: #64748b;
}
/* ---- pink theme (sakura × frost — the original ModelRouter palette) ---- */
[data-theme='pink'] {
--w4f-primary: #FF7FAC;
--w4f-primary-h: #F33B7C;
--w4f-primary-50: #FFF0F5;
@ -31,43 +202,40 @@
--w4f-info: #3f6ef5;
--w4f-ok: #17a964;
/* ---- surfaces / text ---- */
--w4f-bg-1: #eef2ff;
--w4f-bg-2: #ffffff;
--w4f-bg-3: #ffe9f0;
--w4f-bg-soft: #f8f6fb;
--w4f-bg-muted: #f4f1f7;
--w4f-bg-hover: #f5eef3;
--w4f-bg-active: #ffe4e9;
--w4f-fg: #3b3350;
--w4f-fg-2: #5a5470;
--w4f-muted: #7c7a95;
--w4f-faint: #9c98b0;
--w4f-card: rgba(255, 255, 255, 0.60);
--w4f-card-solid: #ffffff;
--w4f-card-2: rgba(255, 255, 255, 0.42);
--w4f-line: rgba(255, 127, 172, 0.18);
--w4f-line-2: rgba(255, 127, 172, 0.12);
--w4f-line-3: rgba(255, 127, 172, 0.06);
--w4f-line-strong: rgba(120, 90, 150, 0.22);
/* ---- background blobs ---- */
--w4f-blob1: rgba(255, 127, 172, 0.45);
--w4f-blob2: rgba(136, 192, 208, 0.42);
--w4f-blob3: rgba(244, 114, 182, 0.30);
/* ---- shadows / radii / motion ---- */
--w4f-sh-sm: 0 1px 2px rgba(70, 50, 110, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
--w4f-sh-md: 0 6px 20px rgba(120, 90, 160, 0.13);
--w4f-sh-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
--w4f-glass: 18px;
--w4f-radius: 16px;
--w4f-radius-sm: 12px;
--w4f-radius-xs: 10px;
--w4f-radius-pill: 99px;
--w4f-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--w4f-ease: cubic-bezier(0.4, 0, 0.2, 1);
--w4f-font: 'Quicksand', 'Nunito', 'Inter', 'Noto Sans SC', ui-sans-serif,
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'PingFang SC', 'Microsoft YaHei', sans-serif;
--w4f-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
/* ---- Element Plus variable overrides (concrete hex — EP needs real
* colors for its color-mix / opacity helpers, not var() references). ---- */
--w4f-glow: rgba(255, 127, 172, 0.35);
--w4f-danger-glow: rgba(219, 54, 148, 0.30);
--w4f-glow-soft: rgba(255, 127, 172, 0.18);
--w4f-thumb: rgba(255, 182, 193, 0.45);
--w4f-thumb-hover: rgba(255, 127, 172, 0.7);
--w4f-selection: #ffcdba;
--el-color-primary: #FF7FAC;
--el-color-primary-light-3: #FF9EB5;
--el-color-primary-light-5: #ffb3c4;
@ -79,9 +247,6 @@
--el-color-warning: #b7791f;
--el-color-danger: #db3694;
--el-color-info: #7c7a95;
--el-border-radius-base: 12px;
--el-border-radius-small: 8px;
--el-font-family: var(--w4f-font);
}
html,
@ -102,7 +267,7 @@ body {
text-rendering: optimizeLegibility;
}
::selection { background: #ffcdba; color: #fff; }
::selection { background: var(--w4f-selection); color: #fff; }
a { color: var(--w4f-primary-h); }
/* ---- animated gradient-mesh background ----
@ -227,21 +392,21 @@ a { color: var(--w4f-primary-h); }
color: #fff; border: 0; border-radius: 11px; padding: 8px 16px; cursor: pointer;
font: inherit; font-weight: 600; transition: all 0.18s var(--w4f-ease-spring);
}
.w4f-btn:hover { background: linear-gradient(135deg, var(--w4f-primary-h), var(--w4f-danger)); transform: translateY(-1px); box-shadow: 0 8px 20px rgba(255, 127, 172, 0.35); }
.w4f-btn:hover { background: linear-gradient(135deg, var(--w4f-primary-h), var(--w4f-danger)); transform: translateY(-1px); box-shadow: 0 8px 20px var(--w4f-glow); }
.w4f-btn:active { transform: translateY(0); }
.w4f-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
.w4f-btn.ghost { background: transparent; border: 1px solid var(--w4f-line-strong); color: var(--w4f-muted); }
.w4f-btn.ghost:hover { border-color: var(--w4f-primary); color: var(--w4f-primary-h); box-shadow: var(--w4f-sh-sm); transform: none; background: transparent; }
.w4f-btn.danger { background: transparent; border: 1px solid var(--w4f-danger); color: var(--w4f-danger); }
.w4f-btn.danger:hover { background: var(--w4f-danger); color: #fff; box-shadow: 0 8px 20px rgba(219, 54, 148, 0.3); transform: translateY(-1px); }
.w4f-btn.danger:hover { background: var(--w4f-danger); color: #fff; box-shadow: 0 8px 20px var(--w4f-danger-glow); transform: translateY(-1px); }
.w4f-btn.small { padding: 5px 12px; font-size: 12px; border-radius: var(--w4f-radius-pill); }
/* ---- slim themed scrollbars ---- */
* { scrollbar-width: thin; scrollbar-color: var(--w4f-line-strong) transparent; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: rgba(255, 182, 193, 0.45); border-radius: 4px; transition: background 0.3s; }
*::-webkit-scrollbar-thumb:hover { background: rgba(255, 127, 172, 0.7); }
*::-webkit-scrollbar-thumb { background: var(--w4f-thumb); border-radius: 4px; transition: background 0.3s; }
*::-webkit-scrollbar-thumb:hover { background: var(--w4f-thumb-hover); }
/* ---- Element Plus dialog tweaks to read as glass ---- */
.el-overlay { backdrop-filter: blur(3px); }

View File

@ -1,55 +1,57 @@
// Global SCSS variables for webui4frpc.
//
// Palette mirrors the CSS custom-property tokens in theme.css (sakura × frost
// glassmorphism, faithful to the ModelRouter WebUI). These SCSS vars are
// auto-injected into every <style lang="scss"> block via vite.config.ts
// additionalData, so remapping them here shifts the accent color of every
// view at once — keeping the data-dense pages visually coherent with the
// glass sidebar shell without per-view rewrites.
// These are thin passthroughs to the runtime CSS custom-property tokens
// defined in theme.css (--w4f-*). They are auto-injected into every
// <style lang="scss"> block via vite.config.ts additionalData, so every view
// follows the active theme (white default / blue / pink) without per-view
// rewrites. There is NO SCSS color math on these (no lighten/darken/mix), so
// a var() reference is safe at compile time and resolves to the live theme
// color in the browser.
//
// Semantic note: green = online/ok (as in ModelRouter). Sakura is the accent.
$color-text-primary: #3b3350;
$color-text-secondary: #5a5470;
$color-text-muted: #7c7a95;
$color-text-light: #9c98b0;
$color-bg-primary: #ffffff;
$color-bg-secondary: #f8f6fb;
$color-bg-tertiary: #eef2ff;
$color-bg-muted: #f4f1f7;
$color-bg-hover: #f5eef3;
$color-bg-active: #ffe4e9;
$color-border: #e6d9e4;
$color-border-light: #ecdfec;
$color-border-lighter: #f2e9f4;
$color-border-extra-light: #f7f0f7;
// Semantic note: green = online/ok (as in ModelRouter). The primary accent is
// theme-driven; online/ok stays green in every theme.
$color-text-primary: var(--w4f-fg);
$color-text-secondary: var(--w4f-fg-2);
$color-text-muted: var(--w4f-muted);
$color-text-light: var(--w4f-faint);
$color-bg-primary: var(--w4f-bg-2);
$color-bg-secondary: var(--w4f-bg-soft);
$color-bg-tertiary: var(--w4f-bg-1);
$color-bg-muted: var(--w4f-bg-muted);
$color-bg-hover: var(--w4f-bg-hover);
$color-bg-active: var(--w4f-bg-active);
$color-border: var(--w4f-line-strong);
$color-border-light: var(--w4f-line);
$color-border-lighter: var(--w4f-line-2);
$color-border-extra-light: var(--w4f-line-3);
// Accents — sakura/frost identity (online/ok stays green).
$color-primary: #ff7fac; // sakura-400
$color-primary-h: #f33b7c; // sakura-500 (hover)
$color-primary-50: #fff0f5; // sakura-50 (tints/chips)
$color-primary-100: #ffe4e9;
$color-secondary: #88c0d0; // frost
$color-secondary-h: #4c8dae;
$color-secondary-50: #f0f9fc;
$color-success: #17a964; // green (online/ok)
$color-warning: #b7791f; // amber
$color-warning-50: #fdf3e3;
$color-danger: #db3694; // sakura-magenta danger
$color-danger-50: #feeaf6;
$color-info: #3f6ef5; // periwinkle
// Primary action button follows the sakura accent (gradient with danger).
$color-btn-primary: #ff7fac;
$color-btn-primary-hover: #f33b7c;
// Accents — identity follows the active theme (online/ok stays green).
$color-primary: var(--w4f-primary);
$color-primary-h: var(--w4f-primary-h);
$color-primary-50: var(--w4f-primary-50);
$color-primary-100: var(--w4f-primary-100);
$color-secondary: var(--w4f-secondary);
$color-secondary-h: var(--w4f-secondary-h);
$color-secondary-50: var(--w4f-secondary-50);
$color-success: var(--w4f-ok);
$color-warning: var(--w4f-warning);
$color-warning-50: var(--w4f-warning-50);
$color-danger: var(--w4f-danger);
$color-danger-50: var(--w4f-danger-50);
$color-info: var(--w4f-info);
// Primary action button follows the theme accent.
$color-btn-primary: var(--w4f-primary);
$color-btn-primary-hover: var(--w4f-primary-h);
// Glass surfaces / shadows / radii (opt-in from views; see theme.css for the
// matching runtime CSS custom properties --w4f-*).
$color-glass-bg: rgba(255, 255, 255, 0.60);
$color-glass-bg-2: rgba(255, 255, 255, 0.42);
$color-glass-border: rgba(255, 127, 172, 0.18);
$shadow-sm: 0 1px 2px rgba(70, 50, 110, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
$shadow-md: 0 6px 20px rgba(120, 90, 160, 0.13);
$shadow-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
$radius-glass: 16px;
// Glass surfaces / shadows (opt-in from views; see theme.css for the runtime
// custom properties --w4f-*).
$color-glass-bg: var(--w4f-card);
$color-glass-bg-2: var(--w4f-card-2);
$color-glass-border: var(--w4f-line);
$shadow-sm: var(--w4f-sh-sm);
$shadow-md: var(--w4f-sh-md);
$shadow-lg: var(--w4f-sh-lg);
$radius-glass: var(--w4f-glass);
$font-size-xs: 12px;
$font-size-sm: 13px;
@ -70,4 +72,4 @@ $radius-md: 16px;
$radius-lg: 16px;
$radius-pill: 99px;
$transition-fast: 0.18s var(--w4f-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
$ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
$ease-spring: var(--w4f-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));

View File

@ -74,6 +74,13 @@ export interface Link {
remotePort: number;
offsetX?: number;
offsetY?: number;
// group: management label for one-click group start/stop on the forwards
// page (unrelated to frps load-balancing lbGroup on Local).
group?: string;
// disabled: per-forward stop flag. A disabled forward is omitted from the
// frpc config (local) / left out of the ring topology (cluster), so stop is
// per-card and survives canvas re-saves.
disabled?: boolean;
}
export interface CanvasData {
@ -130,6 +137,28 @@ export interface StatusResp {
binaryPath: string;
profiles: StatusProfile[];
localStatus: LocalStatus[];
// forwards: the forward-centric list rendered as cards on the status page.
forwards: ForwardStatus[];
// selfId: this node's ring ID (== SelfAddr), to tell whether a cluster
// forward's owner is this node (worker runs here) or a peer.
selfId: string;
}
// ForwardStatus is one forward (link) in the forward-centric status view.
// kind distinguishes 本地转发 (localOnly) from 远程转发 (cluster-distributed).
export interface ForwardStatus {
local: string;
remote: string;
remotePort: number;
localOnly: boolean;
kind: "local" | "remote";
ownerId?: string; // remote = topology owner; local = selfId
active: boolean; // remote = in topology; local = local worker running
disabled: boolean; // per-forward stop flag (true = stopped)
group?: string;
localIp?: string;
localPort?: number;
localProto?: string;
}
export interface LocalTargetStatus {
@ -182,7 +211,7 @@ export interface RingNode {
addr: string;
isLeader?: boolean;
alive: boolean;
load: { memPct: number; netPct: number };
load: { memPct: number; netPct: number; forwards?: number };
version?: string;
cache?: string[];
lastSeen?: number;
@ -211,6 +240,7 @@ export interface RingLogEntry {
node: string;
kind: string;
at?: number;
data?: Record<string, any> | null;
}
export interface RingSnapshot {
@ -223,4 +253,88 @@ export interface RingSnapshot {
pending: RingTaskInfo[];
topology: RingTopoEntry[];
log: RingLogEntry[];
// nodeKey: this node's cluster admission key. Newcomers must present it to
// join via this node (handleClusterJoin verifies). Shown on the cluster page
// for the operator to copy; omitted for non-cluster (standalone) nodes.
nodeKey?: string;
}
// ---- Auth / accounts / API keys (M7) ----
export type AccessLevel = 'read' | 'write' | 'admin';
// MeResp is the authenticated principal (GET /me). The frontend uses level to
// gate the UI: viewer (= auditors) sees read + export controls only.
export interface MeResp {
type: 'user' | 'service' | 'key';
name: string;
level: AccessLevel;
userId?: number;
}
export interface User {
id: number;
username: string;
role: 'admin' | 'viewer';
enabled: boolean;
system?: boolean; // flag-synced built-in account (UI read-only)
createdAt: number;
lastLoginAt: number;
}
export interface ApiKey {
id: number;
userId: number;
prefix: string;
label: string;
scope: AccessLevel;
createdAt: number;
lastUsedAt: number;
expiresAt?: number;
}
// ApiKeyCreated is the one-time response from POST /apikeys: the plaintext key
// is shown exactly once and never retrievable again.
export interface ApiKeyCreated {
id: number;
userId: number;
label: string;
scope: AccessLevel;
prefix: string;
createdAt: number;
key: string;
}
export interface CanvasExportEnvelope {
_type: 'webui4frpc-canvas';
version: number;
exportedAt: number;
exporter: string;
canvas: CanvasData;
}
// Worker-log bundle (GET /cluster/logs/export): one entry per alive ring node.
export interface WorkerLogEntry {
name: string;
remote: string;
state: string;
lines: string;
}
export interface NodeLogsResp {
node: string;
workers: WorkerLogEntry[];
}
export interface ClusterWorkerLogNode {
id: string;
addr: string;
ok: boolean;
error?: string;
logs?: NodeLogsResp;
}
export interface WorkerLogBundle {
_type: 'webui4frpc-worker-logs';
version: number;
exportedAt: number;
requester: string;
nodes: ClusterWorkerLogNode[];
}

View File

@ -10,6 +10,11 @@
</div>
<div class="toolbar-right">
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
<button class="btn ghost" @click="exportCanvas">导出</button>
<button class="btn ghost" :disabled="!canWrite" @click="pickImport" title="导入转发表(覆盖当前)">
导入
</button>
<input ref="importInput" type="file" accept=".json,application/json" class="hidden-input" @change="onImportFile" />
<button class="btn save" :disabled="saving" @click="save">
保存配置
</button>
@ -32,7 +37,7 @@
@pane-click="deselectAll"
@edge-click="onEdgeClick"
>
<Background pattern-color="#cfd8dc" :gap="20" />
<Background :pattern-color="patternColor" :gap="20" />
<template #node-local="slotProps">
<LocalNode
@ -58,6 +63,8 @@
:conflicted="isEdgeConflicted((edgeProps as any).id)"
@label-click="onEdgeLabelClick"
@label-drag="onLabelDrag"
@toggle-disabled="onToggleDisabled"
@edit-group="onEditGroup"
/>
</template>
</VueFlow>
@ -87,6 +94,14 @@
@keyup.enter="confirmPort"
/>
</div>
<div class="dlg-row">
<label>分组</label>
<el-input
v-model="groupInput"
placeholder="可选,用于转发页一键启停整组"
@keyup.enter="confirmPort"
/>
</div>
<template #footer>
<button class="btn" @click="portDlg.visible = false">取消</button>
<button class="btn save" @click="confirmPort">确定</button>
@ -98,21 +113,35 @@
<script setup lang="ts">
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Edge, Connection } from '@vue-flow/core'
import type { CanvasLocal, CanvasRemote, CanvasLink } from '../types'
import type { CanvasLocal, CanvasRemote, CanvasLink, CanvasData, CanvasExportEnvelope } from '../types'
import LocalNode from '../components/LocalNode.vue'
import RemoteNode from '../components/RemoteNode.vue'
import PortEdge from '../components/PortEdge.vue'
import { api } from '../api'
import { canWrite } from '../auth'
const nodes = ref<any[]>([])
const edges = ref<Edge[]>([])
const loading = ref(false)
// Background dot-pattern color, resolved from the live theme var so the grid
// recolors on theme switch (vue-flow Background renders pattern-color as an
// SVG fill attribute, which can't resolve var() directly — read the computed
// value and refresh it when <html data-theme> changes).
const patternColor = ref('rgba(120,130,150,0.22)')
let themeObserver: MutationObserver | null = null
const resolvePatternColor = () => {
const v = getComputedStyle(document.documentElement)
.getPropertyValue('--w4f-line-strong')
.trim()
patternColor.value = v || 'rgba(120,130,150,0.22)'
}
// ---- canvas conflict validation ----
interface CanvasConflict {
type: 'port' | 'domain'
@ -140,11 +169,14 @@ const portDlg = ref({
local: '',
remote: '',
remotePort: 0,
group: '',
pendingEdge: null as Edge | null,
})
// portInput is the plain text control for the remote port in the dialog.
const portInput = ref('')
// groupInput is the management-group text control (forwards page grouping).
const groupInput = ref('')
// openPortDlg opens the port editor dialog, seeding the input.
const openPortDlg = (
@ -153,6 +185,7 @@ const openPortDlg = (
port: number,
isNew: boolean,
pending?: Edge | null,
group = '',
) => {
portDlg.value = {
visible: true,
@ -160,9 +193,43 @@ const openPortDlg = (
local,
remote,
remotePort: port,
group,
pendingEdge: pending ?? null,
}
portInput.value = String(port)
groupInput.value = group
}
// ---- auto-layout ----
// Collapsed cards render ~200px tall (the localOnly hint wraps to several
// lines), so the old 140/160 vertical step made neighbours overlap. EST_* is
// the fallback used before the DOM has measured a node (initial load / add);
// autoLayout reads the real offsetHeight so expanded advanced sections stack
// correctly too. offsetHeight ignores the viewport zoom transform, so the
// measurement is zoom-independent.
const LAYOUT = { localX: 60, remoteX: 760, startY: 90, gap: 24, estLocal: 210, estRemote: 200 }
const measuredHeightOf = (id: string): number => {
const el = document.querySelector<HTMLElement>(`.vue-flow__node[data-id="${id}"]`)
return el ? el.offsetHeight : 0
}
// layoutColumns stacks local nodes in the left column and remote nodes in the
// right column, each offset by its height + gap so cards never overlap.
const layoutColumns = (useMeasured: boolean) => {
let ly = LAYOUT.startY
let ry = LAYOUT.startY
for (const n of nodes.value) {
if (n.type === 'local') {
const h = (useMeasured && measuredHeightOf(n.id)) || LAYOUT.estLocal
n.position = { x: LAYOUT.localX, y: ly }
ly += h + LAYOUT.gap
} else {
const h = (useMeasured && measuredHeightOf(n.id)) || LAYOUT.estRemote
n.position = { x: LAYOUT.remoteX, y: ry }
ry += h + LAYOUT.gap
}
}
}
const localOf = (name: string) =>
@ -259,8 +326,8 @@ const addLocal = () => {
id: nodeId('local', name),
type: 'local',
position: {
x: 60,
y: 90 + nodes.value.filter((n) => n.type === 'local').length * 140,
x: LAYOUT.localX,
y: LAYOUT.startY + nodes.value.filter((n) => n.type === 'local').length * (LAYOUT.estLocal + LAYOUT.gap),
},
data,
})
@ -281,8 +348,8 @@ const addRemote = () => {
id: nodeId('remote', name),
type: 'remote',
position: {
x: 760,
y: 90 + nodes.value.filter((n) => n.type === 'remote').length * 160,
x: LAYOUT.remoteX,
y: LAYOUT.startY + nodes.value.filter((n) => n.type === 'remote').length * (LAYOUT.estRemote + LAYOUT.gap),
},
data,
})
@ -338,6 +405,7 @@ const confirmPort = () => {
return
}
d.remotePort = parsed
d.group = groupInput.value.trim()
if (d.isNew && d.pendingEdge) {
const newId = edgeId(
d.pendingEdge.source,
@ -347,6 +415,7 @@ const confirmPort = () => {
const existing = edges.value.find((e) => e.id === newId)
if (existing) {
existing.label = String(d.remotePort)
existing.data = { ...(existing.data as object), group: d.group }
} else {
edges.value.push({
id: newId,
@ -355,7 +424,7 @@ const confirmPort = () => {
label: String(d.remotePort),
type: 'portedge',
animated: false,
data: {},
data: { group: d.group },
})
assignLayers()
}
@ -373,6 +442,7 @@ const confirmPort = () => {
old.id = newId
}
old.label = String(d.remotePort)
old.data = { ...(old.data as object), group: d.group }
}
portDlg.value.visible = false
dirty.value = true
@ -392,6 +462,7 @@ const editEdge = (edge: Edge) => {
Number(edge.label) || (loc ? loc.port : 0),
false,
edge,
(edge.data as any)?.group ?? '',
)
}
@ -417,6 +488,49 @@ const onLabelDrag = ({
}
}
// onToggleDisabled flips the edge's per-forward stop flag from the canvas
// edge badge. A disabled forward is omitted from the frpc config (local) /
// left out of the ring topology (cluster). Marks the canvas dirty so save
// persists the toggle. The "禁/通" badge on the edge is the quick entry;
// the full port editor also carries a disabled checkbox.
const onToggleDisabled = ({ edgeId }: { edgeId: string }) => {
const e = edges.value.find((x) => x.id === edgeId)
if (!e) return
e.data = { ...(e.data as object), disabled: !((e.data as any)?.disabled) }
dirty.value = true
}
// onEditGroup opens a prompt to set the edge's group label (a management
// label for one-click group start/stop on the status page; unrelated to
// frps load-balancing lbGroup). Lists existing group names for quick
// selection; empty input clears the group (moves the forward to 未分组).
const onEditGroup = async ({ edgeId }: { edgeId: string }) => {
const e = edges.value.find((x) => x.id === edgeId)
if (!e) return
const existing = new Set<string>()
for (const ed of edges.value) {
const g = (ed.data as any)?.group ?? ''
if (g) existing.add(g)
}
const hint = existing.size
? `现有分组:${[...existing].join('、')}(留空=移出分组)`
: '输入分组名称(留空=移出分组)'
const cur = (e.data as any)?.group ?? ''
try {
const { value } = await ElMessageBox.prompt(hint, '编辑分组', {
inputValue: cur,
inputPlaceholder: '分组名称',
confirmButtonText: '确定',
cancelButtonText: '取消',
})
const g = (value || '').trim()
e.data = { ...(e.data as object), group: g }
dirty.value = true
} catch {
// user cancelled — no change
}
}
// assignLayers re-computes the layer (per-pair index) of every edge so custom
// port edges bend away from their siblings. Returns the same array mutated in
// place; also used to keep ordering consistent.
@ -491,19 +605,7 @@ const remoteNodes = computed(() =>
nodes.value.filter((n) => n.type === 'remote'),
)
const autoLayout = () => {
let li = 0
let ri = 0
for (const n of nodes.value) {
if (n.type === 'local') {
n.position = { x: 60, y: 90 + li * 140 }
li++
} else {
n.position = { x: 760, y: 90 + ri * 160 }
ri++
}
}
}
const autoLayout = () => layoutColumns(true)
const load = async () => {
loading.value = true
@ -511,26 +613,23 @@ const load = async () => {
const data = await api.canvas()
nodes.value = []
edges.value = []
let li = 0
let ri = 0
for (const l of data.locals || []) {
nodes.value.push({
id: nodeId('local', l.name),
type: 'local',
position: { x: 60, y: 90 + li * 140 },
position: { x: LAYOUT.localX, y: LAYOUT.startY },
data: { ...l },
})
li++
}
for (const r of data.remotes || []) {
nodes.value.push({
id: nodeId('remote', r.name),
type: 'remote',
position: { x: 760, y: 90 + ri * 160 },
position: { x: LAYOUT.remoteX, y: LAYOUT.startY },
data: { ...r },
})
ri++
}
layoutColumns(false)
for (const link of data.links || []) {
const s = nodeId('local', link.local)
const t = nodeId('remote', link.remote)
@ -547,7 +646,12 @@ const load = async () => {
label: String(rp),
type: 'portedge',
animated: false,
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
data: {
offsetX: link.offsetX ?? 0,
offsetY: link.offsetY ?? 0,
group: link.group ?? '',
disabled: link.disabled ?? false,
},
})
}
assignLayers()
@ -575,6 +679,8 @@ const buildPayload = () => {
remotePort: isNaN(rp) || rp <= 0 ? 0 : rp,
offsetX: (e.data as any)?.offsetX ?? 0,
offsetY: (e.data as any)?.offsetY ?? 0,
group: (e.data as any)?.group ?? '',
disabled: (e.data as any)?.disabled ?? false,
})
}
return { locals, remotes, links }
@ -597,7 +703,7 @@ const save = async () => {
nodes.value.push({
id,
type: 'local',
position: posOf.get(id) || { x: 60, y: 90 + li * 140 },
position: posOf.get(id) || { x: LAYOUT.localX, y: LAYOUT.startY + li * (LAYOUT.estLocal + LAYOUT.gap) },
data: { ...l },
})
li++
@ -607,7 +713,7 @@ const save = async () => {
nodes.value.push({
id,
type: 'remote',
position: posOf.get(id) || { x: 760, y: 90 + ri * 160 },
position: posOf.get(id) || { x: LAYOUT.remoteX, y: LAYOUT.startY + ri * (LAYOUT.estRemote + LAYOUT.gap) },
data: { ...r },
})
ri++
@ -623,7 +729,12 @@ const save = async () => {
target: t,
label: String(rp),
type: 'portedge',
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
data: {
offsetX: link.offsetX ?? 0,
offsetY: link.offsetY ?? 0,
group: link.group ?? '',
disabled: link.disabled ?? false,
},
})
}
assignLayers()
@ -636,7 +747,79 @@ const save = async () => {
}
}
onMounted(load)
// ---- canvas export / import (转发表 备份/还原) ----
const importInput = ref<HTMLInputElement | null>(null)
// exportCanvas downloads the full canvas as a metadata-wrapped JSON bundle.
// Read-level: auditors can export the current table at any time.
const exportCanvas = async () => {
try {
const env = await api.exportCanvas()
const blob = new Blob([JSON.stringify(env, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `webui4frpc-canvas-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('已导出转发表')
} catch (e: any) {
ElMessage.error('导出失败: ' + (e.message || e))
}
}
const pickImport = () => {
importInput.value?.click()
}
// onImportFile reads a chosen JSON file (envelope or bare canvas), confirms the
// overwrite, then POSTs it to /canvas/import (full-replace) and reloads.
const onImportFile = async (ev: Event) => {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
const parsed = JSON.parse(text)
// Accept either the { _type, canvas } envelope or a bare { locals, remotes, links }.
const canvas: CanvasData | undefined =
parsed?._type === 'webui4frpc-canvas' ? parsed.canvas : parsed?.locals ? parsed : undefined
if (!canvas) {
ElMessage.warning('文件格式不符:需为导出的信封或裸画布 JSON')
return
}
await ElMessageBox.confirm(
`导入将覆盖当前连接配置(${canvas.locals?.length ?? 0} 本地 / ${canvas.remotes?.length ?? 0} 远程 / ${canvas.links?.length ?? 0} 连线)。继续?`,
'导入转发表',
{ type: 'warning' },
)
await api.importCanvas(parsed as CanvasData | CanvasExportEnvelope)
await load()
ElMessage.success('已导入并应用')
} catch (e: any) {
if (e === 'cancel' || e?.action === 'cancel') return
ElMessage.error('导入失败: ' + (e.message || e))
} finally {
// reset so picking the same file again re-fires change
if (input) input.value = ''
}
}
onMounted(() => {
load()
resolvePatternColor()
themeObserver = new MutationObserver(resolvePatternColor)
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
})
onBeforeUnmount(() => {
themeObserver?.disconnect()
themeObserver = null
})
</script>
<style scoped lang="scss">
@ -692,24 +875,24 @@ onMounted(load)
}
.btn.local-add {
background: #4caf50;
background: var(--w4f-ok);
color: #fff;
&:hover {
background: #43a047;
background: color-mix(in srgb, var(--w4f-ok) 82%, var(--w4f-fg));
}
}
.btn.remote-add {
background: #ff9800;
background: var(--w4f-warning);
color: #fff;
&:hover {
background: #f57c00;
background: color-mix(in srgb, var(--w4f-warning) 82%, var(--w4f-fg));
}
}
.btn.warn {
background: #78909c;
background: var(--w4f-secondary);
color: #fff;
&:hover {
background: #607d8b;
background: var(--w4f-secondary-h);
}
}
.btn.save {
@ -720,6 +903,29 @@ onMounted(load)
}
}
.btn.ghost {
background: transparent;
color: $color-text-muted;
border: 1px solid $color-border;
&:hover:not(:disabled) {
background: $color-glass-bg-2;
color: $color-text-primary;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.hidden-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
}
.save-hint {
font-size: $font-size-sm;
color: $color-text-muted;
@ -734,7 +940,7 @@ onMounted(load)
.flow-canvas {
width: 100%;
height: 100%;
background: #f5f7fa;
background: var(--w4f-bg-muted);
}
@ -761,8 +967,8 @@ onMounted(load)
}
:deep(.vue-flow__edge-label) {
background: rgba(0, 0, 0, 0.72);
color: #fff;
background: color-mix(in srgb, var(--w4f-fg) 75%, transparent);
color: var(--w4f-bg-2);
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
@ -773,7 +979,7 @@ onMounted(load)
:deep(.vue-flow__handle) {
width: 12px;
height: 12px;
border: 2px solid #fff;
border: 2px solid var(--w4f-card-solid);
}
:deep(.vue-flow__node) {

View File

@ -20,7 +20,7 @@
<div class="hero w4f-card">
<div class="hero-left">
<h2 class="page-title">集群令牌环</h2>
<span class="page-sub">令牌环网 · 单轮一周期 · 画布差异命令进令牌 · 增量日志同步</span>
<span class="page-sub">令牌环网 · 转发编排</span>
</div>
<div class="hero-stats" v-if="ring">
<span class="w4f-tag" :class="stateTagClass"><span class="w4f-dot" :class="stateDotClass" />{{ stateLabel }}</span>
@ -28,6 +28,14 @@
<span class="w4f-tag w4f-tag-primary" :title="'cycle ' + ring.cycle">上次同步 <b>{{ lastSyncAgo }}</b></span>
<span v-if="isLeader" class="w4f-tag w4f-tag-primary"><span class="crown">♔</span> 本机 leader</span>
</div>
<!-- nodeKey: this node's cluster admission key. Other nodes must present it
to join via this node. Shown here for the operator to copy. -->
<div class="nodekey-bar" v-if="ring?.nodeKey">
<span class="nk-label">本节点加入密钥</span>
<code class="nk-value">{{ ring.nodeKey }}</code>
<button class="w4f-btn ghost small" @click="copyNodeKey">复制</button>
<span class="nk-hint">其他节点通过本节点加入集群时需提供此密钥</span>
</div>
</div>
<!-- KPI row -->
@ -40,17 +48,14 @@
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" />活跃转发</span><span class="k-ic"></span></div>
<div class="k-val">{{ ring.topology.length }}</div>
<div class="k-sub">已 claim 的集群转发</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: ring.pending.length > 0 }" />待办命令</span><span class="k-ic"></span></div>
<div class="k-val">{{ ring.pending.length }}</div>
<div class="k-sub">随令牌环行待执行</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: lastSyncStale }" />上次同步</span><span class="k-ic"></span></div>
<div class="k-val">{{ lastSyncAgo }}</div>
<div class="k-sub">令牌环心跳 · 停滞即异常</div>
</div>
</div>
@ -72,7 +77,7 @@
<div v-if="ring" class="cluster-body">
<!-- Ring topology -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">○</span>环拓扑<span class="hint">令牌传递链 · leader 青色 · 本机 sakura 高亮 · 离线标红 · 非本机节点可移除</span></h3>
<h3 class="section-title"><span class="k-ic"></span>环拓扑</h3>
<div class="ring-line">
<template v-for="(n, i) in ring.nodes" :key="n.id">
<div class="ring-node" :class="{ leader: n.id === ring.leaderId, dead: !n.alive, self: n.id === ring.selfId }">
@ -97,7 +102,7 @@
<!-- Pending token commands -->
<section class="section w4f-card" v-if="ring.pending.length">
<h3 class="section-title"><span class="k-ic">⚙</span>令牌命令(待执行)<span class="hint">画布差异 / 节点移除产生,随令牌环行由对应节点执行</span></h3>
<h3 class="section-title"><span class="k-ic"></span>令牌命令待执行</h3>
<div class="task-list">
<div v-for="t in ring.pending" :key="t.id" class="task-row" :class="taskClass(t)">
<span class="tk-id">{{ t.id }}</span>
@ -118,7 +123,7 @@
<!-- Active forward topology -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">→</span>活跃转发拓扑<span class="hint">每个转发由 owner 节点持有 · localOnly 转发只在本节点可见</span></h3>
<h3 class="section-title"><span class="k-ic"></span>活跃转发拓扑</h3>
<div class="topo-list">
<div v-for="t in ring.topology" :key="t.taskId" class="topo-row">
<span class="tp-id">{{ t.taskId }}</span>
@ -135,12 +140,31 @@
<!-- Cluster log -->
<section class="section w4f-card">
<h3 class="section-title"><span class="k-ic">≣</span>集群日志(增量同步)<span class="hint">追加式,随令牌 delta 重放到全网一致</span></h3>
<h3 class="section-title">
<span class="k-ic"></span>集群日志
<span class="log-count" v-if="ring.log.length">{{ ring.log.length }} </span>
<div class="title-actions">
<el-dropdown trigger="click" @command="exportLog" :disabled="!ring.log.length">
<button class="w4f-btn ghost small" :disabled="!ring.log.length"> 导出</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">JSON完整结构</el-dropdown-item>
<el-dropdown-item command="txt">文本可读 TSV</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<button class="w4f-btn ghost small" :disabled="workerLogBusy" @click="exportWorkerLogs">
导出 worker 日志
</button>
</div>
</h3>
<div class="log-list">
<div v-for="(e, i) in logView" :key="i" class="log-row" :class="logKindClass(e.kind)">
<div v-for="e in logView" :key="e.seq" class="log-row" :class="logKindClass(e.kind)">
<span class="lg-seq">#{{ e.seq }}</span>
<span class="lg-time">{{ formatTs(e.at) }}</span>
<span class="lg-node" :class="{ self: e.node === ring.selfId }">{{ shortId(e.node) }}</span>
<span class="lg-kind">{{ kindLabel(e.kind) }}</span>
<span class="lg-detail">{{ detailOf(e) }}</span>
</div>
<div v-if="!logView.length" class="empty">日志为空</div>
</div>
@ -152,12 +176,16 @@
<el-dialog v-model="joinDialog.visible" title="加入集群" width="420px">
<div class="form-row">
<label>对端地址</label>
<el-input v-model="joinDialog.addr" placeholder="host:portnode-a:7500" @keyup.enter="onJoin" />
<el-input v-model="joinDialog.addr" placeholder="对端 IP:port192.168.1.10:7500" @keyup.enter="onJoin" />
</div>
<div class="form-hint">本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。</div>
<div class="form-row">
<label>加入密钥</label>
<el-input v-model="joinDialog.key" placeholder="对端节点的加入密钥nodeKey" @keyup.enter="onJoin" />
</div>
<div class="form-hint">填入对端节点可被本机路由到达的真实 IP:port或域名:port本节点将向对端请求加入并采纳其环状态令牌到达即并入环需提供对端节点的加入密钥以通过验证</div>
<template #footer>
<button class="w4f-btn ghost small" @click="joinDialog.visible = false">取消</button>
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim()" @click="onJoin">加入</button>
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim() || !joinDialog.key.trim()" @click="onJoin">加入</button>
</template>
</el-dialog>
</div>
@ -167,13 +195,14 @@
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import type { RingSnapshot, RingTaskInfo } from '../types'
import type { RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
const ring = ref<RingSnapshot | null>(null)
const busy = ref(false)
let timer: number | null = null
let visHandler: (() => void) | null = null
const joinDialog = reactive({ visible: false, addr: '' })
const joinDialog = reactive({ visible: false, addr: '', key: '' })
const load = async () => {
try { ring.value = await api.ring() } catch { /* keep last */ }
@ -181,6 +210,20 @@ const load = async () => {
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
// copyNodeKey copies this node's admission key to the clipboard so the operator
// can paste it into another node's "加入集群" dialog. Falls back to a select+
// execCommand on browsers without async clipboard (non-secure contexts).
const copyNodeKey = async () => {
const key = ring.value?.nodeKey
if (!key) return
try {
await navigator.clipboard.writeText(key)
ElMessage.success('已复制加入密钥')
} catch {
ElMessage.error('复制失败,请手动选中复制')
}
}
// ---- state derivation (safety gates) ----
const selfInRing = computed(() => !!ring.value?.nodes.some((n) => n.id === ring.value?.selfId))
const aliveCount = computed(() => ring.value?.nodes.filter((n) => n.alive).length ?? 0)
@ -249,6 +292,90 @@ const logKindClass = (k: string) => {
}
const logView = computed(() => [...(ring.value?.log ?? [])].reverse())
// ---- log formatting + export ----
// formatTs renders the entry timestamp (unix sec) as HH:MM:SS (local tz) for
// a compact log feed; the raw epoch is preserved in the exported JSON.
const formatTs = (at?: number): string => {
if (!at) return '--:--:--'
const dt = new Date(at * 1000)
const p = (n: number) => String(n).padStart(2, '0')
return `${p(dt.getHours())}:${p(dt.getMinutes())}:${p(dt.getSeconds())}`
}
// detailOf derives a one-line human summary from each kind's payload (Data).
// forward.add/remove → "local → remote · taskId"; node.join → "node @ addr";
// node.leave → "node"; leader.change → "→ leader".
const detailOf = (e: RingLogEntry): string => {
const d = (e.data || {}) as Record<string, any>
switch (e.kind) {
case 'forward.add':
case 'forward.remove':
return `${d.local || '?'}${d.remote || '?'}${d.taskId ? ' · ' + String(d.taskId).slice(-8) : ''}`
case 'node.join':
return `${d.node || '?'} @ ${d.addr || '?'}`
case 'node.leave':
return `${d.node || '?'}`
case 'leader.change':
return `${d.leader || '?'}`
default:
return d && Object.keys(d).length ? JSON.stringify(d) : ''
}
}
// exportLog downloads the FULL cluster log (snapshot carries every entry) as
// either pretty JSON (lossless) or a readable TSV text file, sorted by seq.
const exportLog = (fmt: string) => {
const entries = [...(ring.value?.log ?? [])].sort((a, b) => a.seq - b.seq)
if (!entries.length) return
let content = ''
let mime = 'text/plain'
let ext = 'log'
if (fmt === 'json') {
content = JSON.stringify(entries, null, 2)
mime = 'application/json'
ext = 'json'
} else {
const header = '#seq\ttime\tnode\tkind\tdetail'
const lines = entries.map((e) =>
`#${e.seq}\t${formatTs(e.at)}\t${shortId(e.node)}\t${e.kind}\t${detailOf(e)}`,
)
content = [header, ...lines].join('\n')
}
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `cluster-log-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.${ext}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// exportWorkerLogs fans out across the ring to gather every node's frpc worker
// logs (the actual forwards running on each node) into one downloadable JSON.
// Read-level: auditors use this to inspect worker output cluster-wide.
const workerLogBusy = ref(false)
const exportWorkerLogs = async () => {
workerLogBusy.value = true
try {
const bundle = await api.exportWorkerLogs()
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `worker-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
const reachable = (bundle.nodes || []).filter((n) => n.ok).length
ElMessage.success(`已导出 worker 日志(${reachable}/${bundle.nodes?.length ?? 0} 节点)`)
} catch (e: any) {
ElMessage.error('导出失败: ' + (e.message || e))
} finally {
workerLogBusy.value = false
}
}
const taskClass = (t: RingTaskInfo) => (t.removeNode ? 'remove' : t.revoke ? 'revoke' : 'pending')
const taskCmdLabel = (t: RingTaskInfo) => (t.removeNode ? '移除节点' : t.revoke ? '撤销' : '新增')
@ -267,11 +394,14 @@ const onCreate = async () => {
}
const onJoin = async () => {
const addr = joinDialog.addr.trim()
if (!addr) return
const key = joinDialog.key.trim()
if (!addr || !key) return
busy.value = true
try {
ring.value = await api.clusterJoinRing(addr)
ring.value = await api.clusterJoinRing(addr, key)
joinDialog.visible = false
joinDialog.addr = ''
joinDialog.key = ''
ElMessage.success(`已加入 ${addr}`)
} catch (e: any) {
ElMessage.error('加入失败: ' + (e.message || e))
@ -310,12 +440,19 @@ const onRemoveNode = async (id: string) => {
onMounted(() => {
load()
timer = window.setInterval(load, 3000)
// 2s cadence so the cluster ring (pending→topology transitions, worker
// spawns) reflects near-real-time; visibilitychange reloads on tab refocus.
timer = window.setInterval(load, 2000)
tick = window.setInterval(() => { now.value = Date.now() }, 1000)
visHandler = () => {
if (!document.hidden) load()
}
document.addEventListener('visibilitychange', visHandler)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
if (tick !== null) { clearInterval(tick); tick = null }
if (visHandler) { document.removeEventListener('visibilitychange', visHandler); visHandler = null }
})
</script>
@ -331,6 +468,12 @@ onBeforeUnmount(() => {
.hero-stats .w4f-tag b { color: $color-text-primary; font-weight: 800; }
.crown { color: var(--w4f-primary-h); }
/* nodeKey admission-key bar */
.nodekey-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--w4f-line); width: 100%; }
.nk-label { font-size: 12px; color: $color-text-muted; white-space: nowrap; }
.nk-value { font-family: var(--w4f-mono); font-size: 12px; background: var(--w4f-card-2); border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 3px 8px; user-select: all; letter-spacing: 0.02em; }
.nk-hint { font-size: 11px; color: $color-text-light; }
/* KPI */
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 14px; }
@ -344,7 +487,6 @@ onBeforeUnmount(() => {
.section-title { margin: 0 0 12px; font-size: 13.5px; font-weight: 700; color: $color-text-muted; letter-spacing: 0.02em; display: flex; align-items: center; gap: 10px;
.k-ic { width: 26px; height: 26px; border-radius: 9px; display: grid; place-items: center; font-size: 13px;
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50)); color: var(--w4f-primary-h); }
.hint { font-size: 11px; font-weight: 400; color: $color-text-muted; margin-left: auto; }
}
/* ring topology */
@ -366,6 +508,7 @@ onBeforeUnmount(() => {
/* tasks / topology / log lists */
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
.log-list { max-height: 340px; overflow-y: auto; padding-right: 2px; }
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 8px 11px; font-size: 13px; background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm); }
.task-row.pending { background: var(--w4f-primary-50); border-color: rgba(255, 127, 172, 0.25); }
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: rgba(219, 54, 148, 0.25); }
@ -374,15 +517,19 @@ onBeforeUnmount(() => {
.tk-arrow { color: var(--w4f-primary); font-weight: 700; }
.tk-port, .tp-port { color: $color-text-muted; font-family: var(--w4f-mono); }
.tp-owner { margin-left: auto; font-size: 11px; color: $color-secondary-h; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
.log-row { display: flex; gap: 10px; font-size: 12px; font-family: var(--w4f-mono); border-bottom: 1px dashed var(--w4f-line); padding: 3px 2px; }
.log-row { display: flex; align-items: center; gap: 10px; font-size: 12px; font-family: var(--w4f-mono); border-bottom: 1px dashed var(--w4f-line); padding: 4px 2px; flex-wrap: wrap; }
.log-row.add .lg-kind { color: $color-success; font-weight: 700; }
.log-row.remove .lg-kind { color: $color-danger; font-weight: 700; }
.log-row.join .lg-kind { color: $color-info; font-weight: 700; }
.log-row.leave .lg-kind { color: $color-warning; font-weight: 700; }
.log-row.leader .lg-kind { color: $color-secondary-h; font-weight: 700; }
.lg-seq { color: $color-text-muted; }
.lg-node { color: $color-text-light; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
.lg-kind { color: $color-text-secondary; }
.lg-seq { color: $color-text-muted; flex: 0 0 auto; }
.lg-time { color: $color-text-light; font-size: 11.5px; flex: 0 0 auto; }
.lg-node { color: $color-text-light; flex: 0 0 auto; &.self { color: var(--w4f-primary-h); font-weight: 700; } }
.lg-kind { color: $color-text-secondary; flex: 0 0 auto; font-weight: 600; }
.lg-detail { margin-left: auto; color: $color-text-secondary; font-size: 11.5px; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
.log-count { font-size: 11px; font-weight: 600; color: $color-text-muted; }
.title-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.empty { color: $color-text-muted; font-size: 13px; padding: 10px; }
.empty-page { color: $color-text-muted; font-size: 14px; padding: 40px; text-align: center; }

View File

@ -281,8 +281,8 @@ onMounted(load)
}
.info-version {
background: rgba(64, 158, 255, 0.12);
color: $color-primary;
background: $color-primary-50;
color: $color-primary-h;
border-radius: 10px;
padding: 2px 8px;
font-size: $font-size-sm;

View File

@ -4,7 +4,7 @@
<div class="topbar w4f-card">
<div class="tb-left">
<h2 class="page-title">状态总览</h2>
<span class="page-sub"> 5 秒自动刷新 · frpc worker 进程态 + frps 连接态</span>
<span class="page-sub"> 2.5 秒自动刷新</span>
</div>
<div class="tb-right">
<button class="w4f-btn ghost small" @click="loadStatus"> 刷新</button>
@ -37,9 +37,57 @@
</div>
<div class="status-body" v-if="status">
<!-- 远程节点状态 -->
<!-- 转发 (forward-centric): 本地/远程 by localOnly, per-card 启停, 分组一键 -->
<section v-for="g in forwardsByGroup" :key="g.key || '__none__'" class="section">
<h3 class="section-title">
<span class="k-ic"></span>{{ g.label }}
<span class="grp-count">{{ g.items.length }} </span>
<span class="grp-actions">
<button class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
<button class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
</span>
</h3>
<div class="card-grid">
<div
v-for="f in g.items"
:key="f.local + ':' + f.remote + ':' + f.remotePort"
class="fwd-card w4f-card"
:class="{ active: f.active, stopped: f.disabled }"
>
<div class="fwd-head">
<span class="fwd-kind" :class="f.kind">{{ f.kind === 'local' ? '本地' : '远程' }}</span>
<span
class="fwd-dot"
:class="{ on: f.active }"
:title="f.active ? '运行中' : f.disabled ? '已停止' : '未运行'"
/>
<span class="fwd-route">
<b>{{ f.local }}</b><span class="fwd-ip" v-if="f.localIp"> {{ f.localIp }}:{{ f.localPort }}</span>
<span class="fwd-arrow"></span>
<b>{{ f.remote }}</b>:<span class="fwd-port">{{ f.remotePort }}</span>
</span>
<span v-if="f.localProto" class="w4f-tag w4f-tag-primary">{{ f.localProto }}</span>
<span
class="w4f-tag w4f-tag-info group-chip"
@click="editGroup(f)"
:title="'点击修改分组(当前:' + (f.group || '未分组') + ''"
>{{ f.group || '未分组' }}</span>
<span v-if="f.kind === 'remote'" class="fwd-owner" :title="f.ownerId || ''">{{ ownerLabel(f) }}</span>
<span class="fwd-actions">
<button v-if="!f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
<button v-else class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
</span>
</div>
</div>
<div v-if="!g.items.length" class="empty">该组暂无转发</div>
</div>
</section>
<div v-if="!status.forwards.length" class="empty">尚未配置转发 前往连接配置画布编排 localremote 连线</div>
<!-- 远程 frps 节点 (只读连接态; 启停已移至转发卡) -->
<section class="section">
<h3 class="section-title"><span class="k-ic"></span>远程节点状态<span class="hint">管理本地 frpc worker 进程不管理远程 frps</span></h3>
<h3 class="section-title"><span class="k-ic"></span>远程节点状态</h3>
<div class="card-grid">
<div
v-for="p in status.profiles"
@ -53,8 +101,6 @@
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
</span>
<span class="nc-actions">
<button v-if="p.process.state === 'running'" class="w4f-btn ghost small" @click="stopRemote(p.name)"> worker</button>
<button v-else class="w4f-btn small" @click="startRemote(p.name)"> worker</button>
<button class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
<button class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
</span>
@ -95,15 +141,16 @@
</div>
</section>
<!-- 本地服务转发状态 -->
<!-- 本地服务 (overview) -->
<section class="section">
<h3 class="section-title"><span class="k-ic"></span>本地服务转发<span class="hint">本地服务经 frpc worker 转发至远程 frps</span></h3>
<h3 class="section-title"><span class="k-ic"></span>本地服务</h3>
<div class="card-grid">
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-card w4f-card">
<div class="lc-head">
<span class="lc-name">{{ ls.local.name }}</span>
<span class="lc-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="w4f-tag w4f-tag-primary">{{ ls.local.protocol }}</span>
<span v-if="ls.local.localOnly" class="w4f-tag w4f-tag-ok">本地</span>
</div>
<div class="lc-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="lc-target">
@ -157,12 +204,13 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import type { Remote, StatusResp } from '../types'
import type { ForwardStatus, Remote, StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
let timer: number | null = null
let visHandler: (() => void) | null = null
interface RemoteDialog {
visible: boolean
@ -216,24 +264,93 @@ const saveRemote = async () => {
}
}
const startRemote = async (name: string) => {
// ---- Per-forward start/stop (forwards page) ----
// local-only → toggle local frpc worker (render skips disabled proxies);
// cluster → ring submit/revoke. Idempotent on the backend.
const startForward = async (f: ForwardStatus) => {
try {
await api.profileStart(name)
await api.forwardStart(f.local, f.remote, f.remotePort)
await loadStatus()
} catch (e: any) {
ElMessage.error('启动失败: ' + (e.message || e))
ElMessage.error('启动转发失败: ' + (e.message || e))
}
}
const stopForward = async (f: ForwardStatus) => {
try {
await api.forwardStop(f.local, f.remote, f.remotePort)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止转发失败: ' + (e.message || e))
}
}
const groupStart = async (group: string) => {
try {
await api.groupStart(group)
await loadStatus()
} catch (e: any) {
ElMessage.error('启动分组失败: ' + (e.message || e))
}
}
const groupStop = async (group: string) => {
try {
await api.groupStop(group)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止分组失败: ' + (e.message || e))
}
}
const stopRemote = async (name: string) => {
// editGroup opens a prompt to change a single forward's group label from the
// card's group chip. Lists existing group names for quick selection; empty
// input moves the forward to 未分组. Pure DB update — no worker/ring action.
const editGroup = async (f: ForwardStatus) => {
const existing = new Set<string>()
for (const fw of status.value?.forwards ?? []) {
if (fw.group) existing.add(fw.group)
}
const hint = existing.size
? `现有分组:${[...existing].join('、')}(留空=移出分组)`
: '输入分组名称(留空=移出分组)'
try {
await api.profileStop(name)
const { value } = await ElMessageBox.prompt(hint, '修改分组', {
inputValue: f.group || '',
inputPlaceholder: '分组名称',
confirmButtonText: '确定',
cancelButtonText: '取消',
})
const g = (value || '').trim()
await api.assignGroup(f.local, f.remote, f.remotePort, g)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止失败: ' + (e.message || e))
// user cancelled (reject) → no action; real errors show a toast
if (e !== 'cancel' && e?.message) {
ElMessage.error('修改分组失败: ' + (e.message || e))
}
}
}
// deleteGroup dissolves a named group: all members are moved to 未分组. The
// group itself is just a label on links, so clearing all members is the
// complete delete. Confirms first since it affects every forward in the group.
const deleteGroup = async (group: string) => {
if (!window.confirm(`删除分组「${group}」?组内所有转发将移至未分组(转发本身不受影响)。`)) return
try {
await api.deleteGroup(group)
await loadStatus()
ElMessage.success(`已删除分组「${group}`)
} catch (e: any) {
ElMessage.error('删除分组失败: ' + (e.message || e))
}
}
// ownerLabel: human-readable topology owner for a remote (cluster) forward.
const ownerLabel = (f: ForwardStatus) => {
if (f.kind !== 'remote') return ''
if (!f.ownerId) return '待分配'
if (f.ownerId === status.value?.selfId) return '本机持有'
return '持有 ' + f.ownerId
}
const removeRemote = async (name: string) => {
if (!window.confirm(`删除远程节点 ${name}`)) return
try {
@ -257,7 +374,29 @@ const workerHealthy = (s?: string) => s === 'running'
const runningCount = computed(() => status.value?.profiles.filter((p) => p.process.state === 'running').length ?? 0)
const onlineCount = computed(() => status.value?.profiles.filter((p) => p.connState === 'connected').length ?? 0)
const allConnected = computed(() => (status.value?.profiles.length ?? 0) > 0 && onlineCount.value === status.value?.profiles.length)
const activeForwards = computed(() => status.value?.profiles.reduce((s, p) => s + p.forwards.length, 0) ?? 0)
const activeForwards = computed(() => status.value?.forwards.filter((f) => f.active).length ?? 0)
// forwardsByGroup buckets the forward-centric list by Link.group; the empty
// group is labelled 未分组 and sorted last so named groups surface first.
const forwardsByGroup = computed(() => {
const map = new Map<string, ForwardStatus[]>()
for (const f of status.value?.forwards ?? []) {
const key = f.group || ''
let arr = map.get(key)
if (!arr) {
arr = []
map.set(key, arr)
}
arr.push(f)
}
return [...map.entries()]
.map(([key, items]) => ({ key, label: key || '未分组', items }))
.sort((a, b) => {
if (!a.key) return 1
if (!b.key) return -1
return a.label.localeCompare(b.label, 'zh')
})
})
// connState refers to the frps connection from the local frpc worker.
// We never control the remote frps itself.
@ -332,7 +471,14 @@ const remoteVhostPort = (name: string): number =>
onMounted(() => {
loadStatus()
timer = window.setInterval(loadStatus, 5000)
// 2.5s cadence keeps the status page live against cluster dispatch +
// worker state changes; visibilitychange reloads instantly on tab refocus
// so a stale tab never lingers on pre-action data.
timer = window.setInterval(loadStatus, 2500)
visHandler = () => {
if (!document.hidden) loadStatus()
}
document.addEventListener('visibilitychange', visHandler)
})
onBeforeUnmount(() => {
@ -340,6 +486,10 @@ onBeforeUnmount(() => {
window.clearInterval(timer)
timer = null
}
if (visHandler) {
document.removeEventListener('visibilitychange', visHandler)
visHandler = null
}
})
</script>
@ -395,7 +545,6 @@ onBeforeUnmount(() => {
gap: 10px;
.k-ic { width: 26px; height: 26px; border-radius: 9px; display: grid; place-items: center; font-size: 13px;
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50)); color: var(--w4f-primary-h); }
.hint { font-size: 11px; font-weight: 400; color: $color-text-muted; margin-left: auto; }
}
.card-grid {
@ -413,6 +562,8 @@ onBeforeUnmount(() => {
.nc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.nc-name { font-weight: 800; font-size: 15px; margin-right: auto; }
.nc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.nc-cluster-note { font-size: 11.5px; color: $color-text-muted; padding: 3px 9px; border-radius: $radius-pill;
background: var(--w4f-card-2); cursor: help; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.meta-pill { font-size: 11.5px; padding: 2px 9px; border-radius: $radius-pill; background: var(--w4f-card-2); color: $color-text-muted;
font-family: var(--w4f-mono); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@ -436,6 +587,37 @@ onBeforeUnmount(() => {
.np-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-proxies-empty { margin-top: 8px; font-size: 12px; color: $color-text-muted; }
/* forward card (转发为单元) */
.fwd-card {
padding: 12px 14px;
border-left: 4px solid var(--w4f-muted);
transition: border-color 0.18s var(--w4f-ease-spring);
&.active { border-left-color: var(--w4f-ok); }
&.stopped { border-left-color: var(--w4f-danger); opacity: 0.78; }
}
.fwd-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.fwd-kind {
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: $radius-pill;
color: var(--w4f-muted); background: var(--w4f-card-2);
&.local { color: var(--w4f-ok-h, #fff); background: var(--w4f-ok-50, var(--w4f-primary-50)); }
&.remote { color: var(--w4f-secondary-h); background: var(--w4f-secondary-50); }
}
.fwd-dot {
width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto;
background: var(--w4f-muted); box-shadow: 0 0 0 3px var(--w4f-card-2);
&.on { background: var(--w4f-ok); box-shadow: 0 0 0 3px var(--w4f-ok-50, var(--w4f-primary-50)); }
}
.fwd-route { font-size: 13.5px; display: flex; align-items: center; gap: 5px; margin-right: auto; }
.fwd-ip { font-size: 11.5px; color: $color-text-muted; font-family: var(--w4f-mono); }
.fwd-arrow { color: var(--w4f-primary); font-weight: 800; }
.fwd-port { font-family: var(--w4f-mono); color: $color-text-secondary; }
.fwd-owner { font-size: 11.5px; color: $color-text-muted; padding: 2px 8px; border-radius: $radius-pill;
background: var(--w4f-card-2); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fwd-actions { display: flex; gap: 6px; }
.grp-count { font-size: 12px; color: $color-text-muted; font-weight: 600; }
.grp-actions { margin-left: auto; display: flex; gap: 6px; }
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } }
/* local service card */
.local-card { padding: 14px 16px; }
.lc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

347
web/src/views/UsersView.vue Normal file
View File

@ -0,0 +1,347 @@
<!--
UsersView account + API key management (admin only).
Two tables: built-in/manual users (system rows are read-only, managed by
-user/-password flags) and scoped API keys (plaintext shown once at create).
Reached only when isAdmin; App.vue hides the nav entry for non-admins.
-->
<template>
<div class="users-view">
<!-- ---- accounts ---- -->
<section class="card">
<header class="card-h">
<h3>账号</h3>
<el-button type="primary" size="small" @click="openUserCreate"> 新建账号</el-button>
</header>
<el-table :data="users" size="small" stripe empty-text="暂无账号">
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="角色" width="110">
<template #default="{ row }">
<el-tag size="small" :type="row.role === 'admin' ? 'danger' : 'info'">
{{ row.role === 'admin' ? '管理员' : '审计(viewer)' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.enabled ? 'success' : 'warning'">
{{ row.enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="110">
<template #default="{ row }">
<el-tag v-if="row.system" size="small" type="warning">内置(flag)</el-tag>
<span v-else class="muted">手动</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="最后登录" width="170">
<template #default="{ row }">{{ row.lastLoginAt ? fmt(row.lastLoginAt) : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openUserEdit(row)" :disabled="row.system">编辑</el-button>
<el-button size="small" type="danger" plain @click="onDeleteUser(row)" :disabled="row.system">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</section>
<!-- ---- api keys ---- -->
<section class="card">
<header class="card-h">
<h3>API 密钥</h3>
<el-button type="primary" size="small" @click="openKeyCreate"> 新建密钥</el-button>
</header>
<el-table :data="apiKeys" size="small" stripe empty-text="暂无密钥">
<el-table-column prop="label" label="标签" min-width="140" />
<el-table-column label="作用域" width="100">
<template #default="{ row }">
<el-tag size="small" :type="scopeType(row.scope)">{{ row.scope }}</el-tag>
</template>
</el-table-column>
<el-table-column label="归属用户" width="140">
<template #default="{ row }">{{ userName(row.userId) }}</template>
</el-table-column>
<el-table-column prop="prefix" label="前缀" width="120" />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="最近使用" width="170">
<template #default="{ row }">{{ row.lastUsedAt ? fmt(row.lastUsedAt) : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" plain @click="onDeleteKey(row)">吊销</el-button>
</template>
</el-table-column>
</el-table>
</section>
<!-- create / edit user dialog -->
<el-dialog v-model="userDialog" :title="editing ? '编辑账号' : '新建账号'" width="420px">
<el-form label-width="86px" @submit.prevent>
<el-form-item label="用户名" v-if="!editing">
<el-input v-model="userForm.username" autocomplete="off" />
</el-form-item>
<el-form-item label="用户名" v-else>
<span class="muted">{{ userForm.username }}</span>
</el-form-item>
<el-form-item :label="editing ? '新密码' : '密码'">
<el-input v-model="userForm.password" type="password" show-password
:placeholder="editing ? '留空则不改' : ''" autocomplete="new-password" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="userForm.role" :disabled="editing && editing?.system">
<el-option label="管理员 (admin)" value="admin" />
<el-option label="审计 (viewer)" value="viewer" />
</el-select>
</el-form-item>
<el-form-item label="启用" v-if="editing">
<el-switch v-model="userForm.enabled" :disabled="editing?.system" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialog = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="userSaving">保存</el-button>
</template>
</el-dialog>
<!-- create key dialog -->
<el-dialog v-model="keyDialog" title="新建 API 密钥" width="440px">
<el-form label-width="86px" @submit.prevent v-if="!createdKey">
<el-form-item label="归属用户">
<el-select v-model="keyForm.userId" placeholder="选择用户">
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-input v-model="keyForm.label" placeholder="如 ci-deploy" />
</el-form-item>
<el-form-item label="作用域">
<el-select v-model="keyForm.scope">
<el-option label="read — 只读 + 导出" value="read" />
<el-option label="write — 改转发/worker/设置" value="write" />
<el-option label="admin — 全量 (含账号管理)" value="admin" />
</el-select>
</el-form-item>
</el-form>
<div v-else class="key-result">
<p class="warn">密钥仅显示一次请立即复制保存</p>
<el-input v-model="createdKey" readonly>
<template #append>
<el-button @click="copyKey">复制</el-button>
</template>
</el-input>
<p class="hint">使用方式请求头 <code>Authorization: Bearer &lt;key&gt;</code></p>
</div>
<template #footer>
<el-button @click="keyDialog = false">{{ createdKey ? '关闭' : '取消' }}</el-button>
<el-button v-if="!createdKey" type="primary" @click="submitKey" :loading="keySaving">生成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import type { ApiKey, ApiKeyCreated, User } from '../types'
const users = ref<User[]>([])
const apiKeys = ref<ApiKey[]>([])
const loadUsers = async () => {
const r = await api.listUsers()
users.value = r.users
}
const loadKeys = async () => {
const r = await api.listApiKeys()
apiKeys.value = r.apiKeys
}
onMounted(async () => {
await Promise.all([loadUsers(), loadKeys()])
})
const fmt = (ts: number) => {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
const userName = (id: number) => users.value.find((u) => u.id === id)?.username ?? `#${id}`
const scopeType = (s: string) => (s === 'admin' ? 'danger' : s === 'write' ? 'warning' : 'info')
// ---- user create/edit ----
const userDialog = ref(false)
const editing = ref<User | null>(null)
const userSaving = ref(false)
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer', enabled: true })
const openUserCreate = () => {
editing.value = null
userForm.value = { username: '', password: '', role: 'viewer', enabled: true }
userDialog.value = true
}
const openUserEdit = (u: User) => {
editing.value = u
userForm.value = { username: u.username, password: '', role: u.role, enabled: u.enabled }
userDialog.value = true
}
const submitUser = async () => {
userSaving.value = true
try {
if (editing.value) {
const patch: { password?: string; role: 'admin' | 'viewer'; enabled: boolean } = {
role: userForm.value.role,
enabled: userForm.value.enabled,
}
if (userForm.value.password) patch.password = userForm.value.password
await api.updateUser(editing.value.username, patch)
ElMessage.success('已更新')
} else {
if (!userForm.value.username || !userForm.value.password) {
ElMessage.warning('用户名和密码必填')
userSaving.value = false
return
}
await api.createUser(userForm.value.username, userForm.value.password, userForm.value.role)
ElMessage.success('已创建')
}
userDialog.value = false
await loadUsers()
} catch (e: any) {
ElMessage.error('失败: ' + (e.message || e))
} finally {
userSaving.value = false
}
}
const onDeleteUser = async (u: User) => {
try {
await ElMessageBox.confirm(`确认删除账号 ${u.username}`, '删除账号', { type: 'warning' })
} catch {
return
}
try {
await api.deleteUser(u.username)
ElMessage.success('已删除')
await loadUsers()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e.message || e))
}
}
// ---- api key create ----
const keyDialog = ref(false)
const keySaving = ref(false)
const createdKey = ref('')
const keyForm = ref({ userId: 0 as number, label: '', scope: 'read' as 'read' | 'write' | 'admin' })
const openKeyCreate = () => {
// default to the first non-system user (or first user)
keyForm.value = {
userId: users.value.find((u) => !u.system)?.id ?? users.value[0]?.id ?? 0,
label: '',
scope: 'read',
}
createdKey.value = ''
keyDialog.value = true
}
const submitKey = async () => {
if (!keyForm.value.userId) {
ElMessage.warning('请选择归属用户')
return
}
keySaving.value = true
try {
const r: ApiKeyCreated = await api.createApiKey(keyForm.value.userId, keyForm.value.label, keyForm.value.scope)
createdKey.value = r.key
await loadKeys()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.message || e))
} finally {
keySaving.value = false
}
}
const copyKey = async () => {
try {
await navigator.clipboard.writeText(createdKey.value)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择')
}
}
const onDeleteKey = async (k: ApiKey) => {
try {
await ElMessageBox.confirm(`确认吊销密钥 ${k.label} (${k.prefix}…)?`, '吊销密钥', { type: 'warning' })
} catch {
return
}
try {
await api.deleteApiKey(k.id)
ElMessage.success('已吊销')
await loadKeys()
} catch (e: any) {
ElMessage.error('吊销失败: ' + (e.message || e))
}
}
</script>
<style scoped>
.users-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.card {
background: var(--w4f-card);
border: 1px solid var(--w4f-line);
border-radius: 16px;
padding: 16px 18px;
box-shadow: var(--w4f-sh-sm);
}
.card-h {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-h h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.muted {
color: var(--w4f-muted);
}
.key-result {
display: flex;
flex-direction: column;
gap: 10px;
}
.key-result .warn {
color: var(--w4f-primary-h, #d97706);
font-weight: 600;
margin: 0;
}
.key-result .hint {
margin: 0;
font-size: 12px;
color: var(--w4f-muted);
}
.key-result code {
background: var(--w4f-card-2);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
}
</style>