diff --git a/README.md b/README.md index 68830ac..f8352e9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ ## 特性 ### 画布配置 + - **Scratch 风格画布**:本地转发项(local)与远程服务器(remote)可视化连线,一个 local 可连多个 remote,一个 remote 可连多个 local - **曲线连线 + 端口标签**:每条连线独立曲线,标签为远程端口,可拖拽调整曲线位置,端口可点开编辑 - **边开关与分组**:画布边上可一键禁用/启用单条转发,可打分组标签(状态页按分组管理、一键启停整组) @@ -15,6 +16,7 @@ - **画布导入导出**:支持 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 …) @@ -22,6 +24,7 @@ - **frpc 一键安装**:从官方 Releases 下载任意版本 frpc,或手动指定二进制路径 ### 集群模式(令牌环) + - **令牌环协议**:若干 webui4frpc 节点组成协作网络;令牌按固定顺序传递,每节点持完整集群信息,单轮即全网收敛 - **负载最低者摘取**:新转发任务附加在令牌中,由负载最低的节点自行摘取并创建 frpc worker - **容错自愈**:leader / 非 leader 节点宕机 → 自动检测 → 环跳过死亡节点、任务重挂;leader 宕机 → 上邻居晋升为新 leader @@ -30,6 +33,7 @@ - **集群二进制分发**:节点间优先互传 frpc 二进制,外部 URL 兜底 ### 认证与权限 + - **Basic Auth**:启动参数配置管理员账号密码(flag-creds 快速路径,不走 bcrypt) - **用户管理**:admin / viewer 两种角色,bcrypt 存储,webui 管理 - **API Key**:read / write / admin 三级 scope,`w4f_` 前缀,sha256 存储,创建时明文仅展示一次 @@ -67,12 +71,12 @@ cd web && npm run build && cd .. && cp -r web/dist/* internal/httpapi/dist/ && g ./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data ``` -打开 http://127.0.0.1:7500/ ,输入账号密码进入画布。首次使用可到「设置」页一键安装 frpc(或手动指定路径),然后回到画布创建 local / remote 并连线、保存。 +打开 ,输入账号密码进入画布。首次使用可到「设置」页一键安装 frpc(或手动指定路径),然后回到画布创建 local / remote 并连线、保存。 ### 参数 | 参数 | 默认 | 说明 | -|---|---|---| +| --- | --- | --- | | `-addr` | `127.0.0.1:7500` | 监听地址 | | `-user` / `-password` | `admin` / `admin` | Basic 认证(自动同步为 system 管理员账号) | | `-workdir` | `./webui-frpc` | 数据目录(db / 配置 / 日志 / bin) | @@ -125,7 +129,7 @@ webui4frpc(单二进制,无 frp 依赖,前端 go:embed) 所有接口前缀 `/api/manager`,使用 Basic Auth 或 Bearer API Key 认证。权限分 read / write / admin 三级。 | 类别 | 方法 | 路径 | 权限 | 说明 | -|---|---|---|---|---| +| --- | --- | --- | --- | --- | | **状态** | GET | `/status` | read | 总览(版本/服务/节点/转发/profiles) | | **画布** | GET/PUT | `/canvas` | read/write | 读写完整画布 | | | GET | `/canvas/export` | read | 导出画布备份 | diff --git a/internal/cluster/netload_linux.go b/internal/cluster/netload_linux.go index e1d94e3..c28f4de 100644 --- a/internal/cluster/netload_linux.go +++ b/internal/cluster/netload_linux.go @@ -196,8 +196,9 @@ func SampleNetLoad() float64 { // // rx+tx counts BOTH directions — a forward proxies in AND out, so a NIC // that's saturated in one direction is saturated for forwarding purposes. + // Capacity comes from the CURRENT snapshot's capMbps (already summed in + // snapshotNet) — no extra /sys reads per interface here. var deltaBits float64 - sharedCap := uint64(0) for name, c := range cur.bytes { p, ok := prev.bytes[name] if !ok { @@ -209,15 +210,11 @@ func SampleNetLoad() float64 { continue } deltaBits += float64(c-p) * 8 - // approximate this interface's capacity contribution proportional to - // its share of the summed speed — read back from /sys would be noisy - // per call, so reuse the prior snapshot's capMbps scaling. - sharedCap += readIfaceSpeed(name) } - if sharedCap == 0 { + if cur.capMbps == 0 { return 0 } - capBitsPerSec := float64(sharedCap) * 1e6 + capBitsPerSec := float64(cur.capMbps) * 1e6 pct := (deltaBits / dt) / capBitsPerSec * 100 if pct < 0 { return 0 diff --git a/internal/cluster/ring.go b/internal/cluster/ring.go index 9cabbb5..0d1cedf 100644 --- a/internal/cluster/ring.go +++ b/internal/cluster/ring.go @@ -81,9 +81,9 @@ type TopoEntry struct { // State is the full cluster picture, replicated on every node: // ring membership + pending tasks + active forward topology. type State struct { - LeaderID string `json:"leaderId"` - Nodes []Node `json:"nodes"` - Cycle int64 `json:"cycle"` + LeaderID string `json:"leaderId"` + Nodes []Node `json:"nodes"` + Cycle int64 `json:"cycle"` // RoundDelay rides the token so every node converges on the same ring // cadence (serialized as nanoseconds; the leader refreshes it each round // via tkDelaySince). MUST be serialized — otherwise a token round-trip diff --git a/internal/cluster/ring_engine.go b/internal/cluster/ring_engine.go index 72416a5..7340d64 100644 --- a/internal/cluster/ring_engine.go +++ b/internal/cluster/ring_engine.go @@ -162,16 +162,17 @@ func (e *Engine) loadSnapshot() Load { // OnToken is the SINGLE-ROUND token handler. Per the authoritative design // (plan §令牌环协议): on receiving the token a node simultaneously -// (a) ADOPTS the carried cluster picture — incoming state is authoritative -// for membership + per-node fields. This is safe because structural -// changes (join/remove) are NOT kept in local state waiting to survive -// a merge: joins ride a separate pendingJoin channel injected INTO the -// token here, and a self-remove writes the node out of state so the -// downstream merge naturally drops it. -// (b) APPLIES the incremental log delta, then re-attaches own fresh entries. -// (c) APPENDS own node info (load/version) — skipped if we self-removed. -// (d) INJECTS pending newcomers right after ourselves + forwards to them. -// (e) Paces via a parallel rhythm timer (max(ops, timer)). +// +// (a) ADOPTS the carried cluster picture — incoming state is authoritative +// for membership + per-node fields. This is safe because structural +// changes (join/remove) are NOT kept in local state waiting to survive +// a merge: joins ride a separate pendingJoin channel injected INTO the +// token here, and a self-remove writes the node out of state so the +// downstream merge naturally drops it. +// (b) APPLIES the incremental log delta, then re-attaches own fresh entries. +// (c) APPENDS own node info (load/version) — skipped if we self-removed. +// (d) INJECTS pending newcomers right after ourselves + forwards to them. +// (e) Paces via a parallel rhythm timer (max(ops, timer)). func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) { // Multi-token guard: only the newest leader-stamped token is kept. if tk.SentAt > 0 && e.lastTokenAt > 0 && tk.SentAt < e.lastTokenAt { diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 9cf61be..7a282aa 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -15,7 +15,7 @@ import ( // 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. +// 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 diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 8badb51..2f81854 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -38,7 +38,10 @@ func (h *Handler) handleCanvasGet(w http.ResponseWriter, _ *http.Request) { for _, r := range remotes { remoteNames[r.Name] = true } - type linkKey struct{ local, remote string; port int } + type linkKey struct { + local, remote string + port int + } linkSeen := make(map[linkKey]bool, len(links)) for _, l := range links { linkSeen[linkKey{l.Local, l.Remote, l.RemotePort}] = true @@ -201,7 +204,10 @@ func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *ca // Build a set of (local, remote, remotePort) triples from the incoming // canvas links so we can revoke any stale topology entries no longer // present in the canvas (e.g. links that were deleted from the UI). - type triple struct{ local, remote string; port int } + type triple struct { + local, remote string + port int + } canvasTriples := make(map[triple]bool, len(canvas.Links)) for _, ln := range canvas.Links { canvasTriples[triple{ln.Local, ln.Remote, ln.RemotePort}] = true diff --git a/internal/httpapi/handlers_logs.go b/internal/httpapi/handlers_logs.go index e6a34e9..4a1eae7 100644 --- a/internal/httpapi/handlers_logs.go +++ b/internal/httpapi/handlers_logs.go @@ -60,10 +60,10 @@ func (h *Handler) handleNodeLogs(w http.ResponseWriter, r *http.Request) { // 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"` + ID string `json:"id"` + Addr string `json:"addr"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` Logs *nodeLogsResp `json:"logs,omitempty"` } diff --git a/internal/httpapi/handlers_users.go b/internal/httpapi/handlers_users.go index 4112470..3020db6 100644 --- a/internal/httpapi/handlers_users.go +++ b/internal/httpapi/handlers_users.go @@ -162,7 +162,7 @@ func (h *Handler) handleApiKeys(w http.ResponseWriter, r *http.Request) { "userId": k.UserID, "label": k.Label, "scope": k.Scope, - "prefix": k.Prefix, + "prefix": k.Prefix, "createdAt": k.CreatedAt, "key": plaintext, // shown exactly once }) diff --git a/internal/store/store.go b/internal/store/store.go index 4343e42..0446188 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -148,10 +148,10 @@ type Forward struct { type User struct { ID int64 `json:"id"` Username string `json:"username"` - PasswordHash string `json:"-"` // never serialized to clients - Role string `json:"role"` // "admin" | "viewer" + 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 + System bool `json:"system"` // true = flag-synced, UI read-only CreatedAt int64 `json:"createdAt"` LastLoginAt int64 `json:"lastLoginAt"` } @@ -162,12 +162,12 @@ type User struct { type ApiKey struct { ID int64 `json:"id"` UserID int64 `json:"userId"` - Prefix string `json:"prefix"` // first 8 chars of plaintext, for display + Prefix string `json:"prefix"` // first 8 chars of plaintext, for display Label string `json:"label"` - Scope string `json:"scope"` // "read" | "write" | "admin" + Scope string `json:"scope"` // "read" | "write" | "admin" CreatedAt int64 `json:"createdAt"` LastUsedAt int64 `json:"lastUsedAt"` - ExpiresAt int64 `json:"expiresAt"` // 0 = never expires + ExpiresAt int64 `json:"expiresAt"` // 0 = never expires } const schema = `