mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07:57 +00:00
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:
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user