feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs

- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix)
- Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope)
- Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels)
- API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints
- Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference)
- Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes
- Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
2026-08-19 21:09:24 +08:00
parent b518a13446
commit eda9bb9597
55 changed files with 5462 additions and 793 deletions

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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