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)
@ -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;` 圆角/字体。
|
||||
- body:14px/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 #9c98b0);glass/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()` 返回 standalone(1 节点=自己,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` 滚动区,各视图自带内边距。
|
||||
## P1:State.Seq 删除 — ring.go + ring_engine.go
|
||||
(plan.md 从未要求 Seq;NextTaskID 注释 L213-221 已自述回归 bug)
|
||||
- ring.go L90:State 结构体删 `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 L635:CreateCluster 删 `Seq: e.state.Seq`
|
||||
- LogEntry.Seq(ring_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 small),meta(pid/err/vhost 药丸),forwards 药丸 chips,per-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` struct(ring_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=红暗;负载 bar;addr 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-556):decode JoinInfo 后加 `if ji.JoinKey != h.Ring.NodeKey() { 403 }`
|
||||
- `JoinRingAddr`(token_join.go L54-57):签名加 `joinKey string`,设 `ji.JoinKey = joinKey`
|
||||
- `JoinRing`(token_join.go L16-47):JoinInfo 已含 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=false,local.ip=`backend` docker DNS,port 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(死地址:验下发+起 worker,conn 失败)
|
||||
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` reactive(L193):加 `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,不误开端口编辑器)
|
||||
- strokeColor:disabled 时灰色(`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 已序列化 disabled,L637 已序列化 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 成为新 leader(cycle 继续推进)→ ring 正常
|
||||
5. **退出残留**:a 退出后 a 的集群页显示 standalone(1 节点、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
@ -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/https(customDomains / subdomain / locations / basicAuth / headerRewrite)
|
||||
- **传输参数**:useEncryption / useCompression / bandwidthLimit / poolCount / transport.protocol(tcp/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 # SQLite(locals / 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 一键下载官方 frpc(GitHub Releases,含解压安全防护)
|
||||
│ ├── cluster 令牌环协议(ring engine / leader 选举 / 容错 / 宕机重联 / 二进制分发)
|
||||
│ └── httpapi REST API + 静态资源(auth / canvas / forwards / cluster / users)
|
||||
└── web/ Vue 3 SPA(Element 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 | 集群 leader(seed,有 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
|
||||
```
|
||||
|
||||
@ -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}}"
|
||||
@ -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
@ -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 个版本。
|
||||
|
||||
**权限**:write(GET 为 read,POST 需 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 超时后回退 CreateCluster(standalone)
|
||||
4. **显式脱离不重联**:通过 `POST /cluster/node-remove` 脱离集群时清除 `ClusterPeers` → 重启后不自动重联
|
||||
|
||||
---
|
||||
|
||||
## 容错行为
|
||||
|
||||
| 场景 | 检测机制 | 恢复动作 |
|
||||
|---|---|---|
|
||||
| 非leader节点宕机 | `forwardToNext` 发送失败 → MarkOffline → OfflineReassign | 环跳过死亡节点,任务重挂为待办 |
|
||||
| leader 宕机(发送时) | `forwardToNext` 发送给 leader 失败 → MarkOffline | 上邻居晋升为新 leader + StartRing |
|
||||
| leader 宕机(收到令牌后) | `WatchLeader` 心跳失败/409 → MarkOffline | 上邻居晋升 + StartRing(兜底) |
|
||||
| leader 快速重启 | 心跳得到 409(standalone) | 上邻居晋升 + StartRing |
|
||||
| 令牌丢失 | `WatchTokenLoss` 超时(LossTimeout) | leader 重发令牌 |
|
||||
7
go.mod
@ -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
@ -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=
|
||||
|
||||
BIN
gui-test-screenshots/root___forwards.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
BIN
gui-test-screenshots/root___status.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
BIN
gui-test-screenshots/root__forwards.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
BIN
gui-test-screenshots/root__status.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
BIN
gui-test-screenshots/root_index.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
77
internal/httpapi/dist/assets/index-C5WLVFP2.js
vendored
Normal file
1
internal/httpapi/dist/assets/index-CL42Ur3_.css
vendored
Normal file
76
internal/httpapi/dist/assets/index-CXBf2fLP.js
vendored
283
internal/httpapi/dist/favicon.svg
vendored
Normal 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 |
7
internal/httpapi/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
257
internal/httpapi/handlers_canvas.go
Normal 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)
|
||||
}
|
||||
278
internal/httpapi/handlers_forwards.go
Normal 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})
|
||||
}
|
||||
146
internal/httpapi/handlers_logs.go
Normal 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)
|
||||
}
|
||||
206
internal/httpapi/handlers_users.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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[:])
|
||||
}
|
||||
|
||||
114
internal/store/users_test.go
Normal 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
@ -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} + web(Vue3 + VueFlow + Element Plus)
|
||||
- [x] 画布模型:local / remote / link 多对多连线,节点可编辑、可添加
|
||||
- [x] 渲染器:tcp/udp/http/https → frpc JSON 配置(http 用 customDomains=`<name>.local`)
|
||||
- [x] worker 进程管理:spawn/stop/restart、日志轮转、崩溃自愈(指数退避)
|
||||
- [x] 一键安装 frpc(GitHub Releases)+ 手动指定路径
|
||||
- [x] REST API:status/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 用 remotePort,http/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 增加 transport:protocol(tcp/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 / adminPassword(local 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 更多代理类型
|
||||
|
||||
- [ ] tcpmux(HTTP/2 多路复用)
|
||||
- [ ] stcp / sudp(点对点加密穿透)
|
||||
- [ ] xtcp(P2P,需 kcp)
|
||||
- [ ] 按类型动态显示字段的表单
|
||||
|
||||
### M5 运维与发布
|
||||
|
||||
- [x] git init + 首次 commit + .gitignore(排除 node_modules / web/dist;.pi/ 本地代理状态已忽略)
|
||||
- [ ] 一键构建脚本(web→dist→embed→单二进制)
|
||||
- [ ] GitHub Actions:release 构建(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. 令牌传递给自身**原本的下一家**。
|
||||
- **脱离集群后的本地视图**:被移除节点脱离后,自身内部维持的拓扑**只保留本地 local(localOnly 不进集群的转发)**,**完全取消一切集群远程转发**(删掉由集群分发的转发、停其 worker)。
|
||||
|
||||
#### 故障自愈(传递令牌间隙判定邻居离线)
|
||||
|
||||
- 邻居离线判定**仅在传递令牌的间隙**发生:上家传递令牌给下家后,若在预期窗口内未收到回执,则判定下家离线 → **修改集群状态、移除该节点、将其负责的任务重新追加为待办**,令牌传给再下家。
|
||||
|
||||
#### 节点消亡(防脑裂最强兜底)
|
||||
|
||||
- 任何节点若**超时收不到令牌**(超过既定周期的 N 倍),**直接自杀退出**:不探测上家、不生成新令牌、不做任何协商。这是防止集群脑裂(split-brain)的最后防线——宁可单一节点自毁,不让两个各自为政的残环长期并存。
|
||||
|
||||
#### 实现里程碑(待按上述设计重建)
|
||||
|
||||
- [x] 数据面:Ring State(节点表/转发链/leader/轮次延迟/负载/待办任务)+ 日志(追加式 + 水位)
|
||||
- [x] 令牌环传输:HTTP POST /cluster/token + 回执;单轮一周期(信息追加与同步同步完成)
|
||||
- [x] leader:创建集群者为首 leader,StartRing 首令牌 + 轮次延迟测量 + 令牌丢失重发(LossTimeout)
|
||||
- [x] 新转发任务:POST /cluster/task 挂 pending → 令牌携带 → 负载最低者摘取 → 持久化 + 拉 worker(e2e 验证)
|
||||
- [x] 增量日志同步:LogForwardAdd/LogNodeJoin 等事件随令牌 delta 传播(单轮内完成同步),各节点重放收敛(e2e 验证)
|
||||
- [x] 拓扑回写:摘取后 state 写入令牌,全网一致(e2e topology=[t1,owner])
|
||||
- [x] 新节点加入:POST /cluster/join → 插入环(后继) → AdoptState(e2e 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 / http(customDomains)、token 认证、多对多、worker 管理
|
||||
- 未支持:stcp/sudp/xtcp/tcpmux、OIDC/TLS 证书、加密/压缩/限速/连接池、LB/健康检查、http 路由高级、插件/虚拟 IP
|
||||
- 优先级:P0 高级传输参数 → P1 域名字段+transport → P2 LB/健康检查 → P3 低频项
|
||||
- UI 原则:高级字段折叠式 / 按类型动态显示,保画布简洁
|
||||
@ -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
@ -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 |
125
web/src/App.vue
@ -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 {
|
||||
|
||||
@ -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
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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));
|
||||
|
||||
116
web/src/types.ts
@ -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[];
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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:port(如 node-a:7500)" @keyup.enter="onJoin" />
|
||||
<el-input v-model="joinDialog.addr" placeholder="对端 IP:port(如 192.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; }
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">尚未配置转发 — 前往「连接配置」画布编排 local→remote 连线</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
@ -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 <key></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>
|
||||