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

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

View File

@ -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
}