mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-19 16:38:31 +00:00
Three issues fixed: 1. Start/stop returned 404 for forwards owned by other nodes: findLinkByTriple only checked local SQLite store. Added findLinkFromTopology fallback — startForward/stopForward now look up ring topology entries when the local store doesn't have the link. 2. Group labels didn't sync through the ring: handleForwardsAssign only updated local SQLite, never the ring topology. Added State.UpdateTopologyGroup + Engine.UpdateTopologyGroup — handleForwardsAssign now updates both. Added topologySync callback called after every e.state = tk.State (OnToken) and e.state = s (AdoptState) to re-apply local store group overrides onto the freshly adopted topology, so they survive state adoption and propagate via the next token forward. handleForwardsGroupDelete also clears ring topology entries. 3. Status-merge branch omitted Group field: ring-only forwards always showed '未分组'. Added Group: t.Link.Group to the topology-merge forwardStatus. Verified: 4-node cluster, 5 forwards, group assigned on node-a propagates to all nodes within one token cycle; stop from non-owning node succeeds (HTTP 200) and worker stops on the owning node.
866 lines
30 KiB
Go
866 lines
30 KiB
Go
// Package httpapi serves the web UI and REST API.
|
||
package httpapi
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"embed"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"webui4frpc/internal/cluster"
|
||
"webui4frpc/internal/process"
|
||
"webui4frpc/internal/store"
|
||
)
|
||
|
||
//go:embed all:dist
|
||
var distFS embed.FS
|
||
|
||
// Handler bundles dependencies for the HTTP API.
|
||
type Handler struct {
|
||
Store *store.Store
|
||
Process *process.Manager
|
||
WorkDir string
|
||
BinDir string
|
||
User string
|
||
Password string
|
||
|
||
// InstallBinary downloads and activates a frpc binary. Set by the app to
|
||
// avoid an import cycle with the install package.
|
||
InstallBinary func(version string) (path, ver string, err error)
|
||
// BinaryPath resolves the current worker binary.
|
||
BinaryPath func() string
|
||
// RunInstall is a hook to trigger canary tasks after canvas save.
|
||
SyncWorkers func()
|
||
// Cluster is the peer-to-peer binary registry (M6). Nil disables M6 routes.
|
||
Cluster *cluster.Registry
|
||
// Ring is the token-ring engine (M6). Nil disables ring routes.
|
||
Ring *cluster.Engine
|
||
// SelfAddr is this node's reachable listen address (from -addr), used for
|
||
// cluster discovery so peers can reach back for binary exchange.
|
||
SelfAddr string
|
||
}
|
||
|
||
const (
|
||
healthzPath = "/healthz"
|
||
apiPrefix = "/api/manager"
|
||
)
|
||
|
||
// NewServeMux builds the full HTTP handler.
|
||
func NewServeMux(h *Handler) (http.Handler, error) {
|
||
mux := http.NewServeMux()
|
||
|
||
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(http.StatusOK)
|
||
})
|
||
|
||
// 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", h.auth("read")(h.handleBinaryStatus))
|
||
mux.HandleFunc(apiPrefix+"/binary/install", h.auth("write")(h.handleBinaryInstall))
|
||
|
||
// Profile lifecycle: status/config/logs = read; start/stop/restart = write
|
||
// (write-checked inside handleProfile).
|
||
mux.HandleFunc(apiPrefix+"/profiles/", h.auth("read")(h.handleProfile))
|
||
|
||
// 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. Peers
|
||
// resolve to admin via the flag fast path.
|
||
mux.HandleFunc("/frpc/", h.auth("read")(h.handleFrpcBinary))
|
||
|
||
// 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
|
||
// loop (the hash fragment never reaches the server).
|
||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||
sub, err := fs.Sub(distFS, "dist")
|
||
if err != nil {
|
||
http.Error(w, "assets unavailable", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
name := strings.TrimPrefix(r.URL.Path, "/")
|
||
if name == "" {
|
||
name = "index.html"
|
||
}
|
||
data, err := fs.ReadFile(sub, name)
|
||
if err != nil {
|
||
// SPA fallback: any non-file path serves index.html.
|
||
data, err = fs.ReadFile(sub, "index.html")
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
name = "index.html"
|
||
}
|
||
if ct := contentTypeFor(name); ct != "" {
|
||
w.Header().Set("Content-Type", ct)
|
||
}
|
||
_, _ = w.Write(data)
|
||
}
|
||
|
||
func contentTypeFor(name string) string {
|
||
switch {
|
||
case strings.HasSuffix(name, ".html"):
|
||
return "text/html; charset=utf-8"
|
||
case strings.HasSuffix(name, ".js"):
|
||
return "application/javascript"
|
||
case strings.HasSuffix(name, ".css"):
|
||
return "text/css"
|
||
case strings.HasSuffix(name, ".svg"):
|
||
return "image/svg+xml"
|
||
case strings.HasSuffix(name, ".png"):
|
||
return "image/png"
|
||
case strings.HasSuffix(name, ".ico"):
|
||
return "image/x-icon"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// proxyState is a per-proxy status row reported by the local frpc admin
|
||
// API (GET /api/status). Status is one of frpc's proxy phases:
|
||
// new | wait start | start error | running | check failed | closed.
|
||
type proxyState struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Status string `json:"status"`
|
||
Err string `json:"err,omitempty"`
|
||
LocalAddr string `json:"local_addr"`
|
||
RemoteAddr string `json:"remote_addr"`
|
||
}
|
||
|
||
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
methodNotAllowed(w)
|
||
return
|
||
}
|
||
locals, _ := h.Store.ListLocals()
|
||
remotes, _ := h.Store.ListRemotes()
|
||
settings, _ := h.Store.Settings()
|
||
|
||
type profileStatus struct {
|
||
Name string `json:"name"`
|
||
Enabled bool `json:"enabled"`
|
||
Status process.Status `json:"process"`
|
||
HasProc bool `json:"hasProcess"`
|
||
Forwards []store.Forward `json:"forwards"`
|
||
// ConnState is the derived frps connection state:
|
||
// connected | connecting | failed | not_started | disabled.
|
||
ConnState string `json:"connState"`
|
||
// AdminEnabled is true when the remote configures a local frpc admin
|
||
// API (adminPort > 0); then ProxyStates reflects real proxy health.
|
||
AdminEnabled bool `json:"adminEnabled"`
|
||
ProxyStates []proxyState `json:"proxyStates,omitempty"`
|
||
// GroupCount is the number of distinct LB groups across this remote's
|
||
// forwards (M3 display).
|
||
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{}
|
||
topoEntries := []*cluster.TopoEntry{} // full topology for canvas/status merge
|
||
selfID := h.SelfAddr
|
||
if h.Ring != nil {
|
||
snap := h.Ring.Snapshot()
|
||
selfID = snap.SelfID
|
||
topoEntries = snap.Topology
|
||
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 {
|
||
binary = h.BinaryPath()
|
||
}
|
||
for _, rv := range remotes {
|
||
st, has := h.Process.Status(rv.Name)
|
||
fwd, _ := h.Store.LinksForRemote(rv.Name)
|
||
p := profileStatus{
|
||
Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd,
|
||
AdminEnabled: rv.AdminPort > 0,
|
||
GroupCount: groupCountOf(locals, fwd),
|
||
}
|
||
if rv.AdminPort > 0 {
|
||
// Query the local frpc admin API for true per-proxy state.
|
||
if states, err := fetchProxyStates(rv.AdminAddr, rv.AdminPort, rv.AdminUser, rv.AdminPassword); err == nil {
|
||
p.ProxyStates = states
|
||
}
|
||
}
|
||
// Prefer admin-derived conn state; fall back to log inference.
|
||
if p.AdminEnabled && len(p.ProxyStates) > 0 {
|
||
p.ConnState = connStateAdmin(p.ProxyStates)
|
||
} else {
|
||
p.ConnState = connStateOf(h.Process.LogPath(rv.Name), rv.Enabled, st)
|
||
}
|
||
profiles = append(profiles, p)
|
||
}
|
||
|
||
// Local status: each local plus its forwarding targets and whether each
|
||
// 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"`
|
||
WorkerState string `json:"workerState"`
|
||
}
|
||
type localStatus struct {
|
||
Local store.Local `json:"local"`
|
||
Targets []localTargetStatus `json:"targets"`
|
||
}
|
||
|
||
localStatuses := make([]localStatus, 0, len(locals))
|
||
for _, l := range locals {
|
||
targets, err := h.Store.LinksForLocal(l.Name)
|
||
if err != nil {
|
||
targets = nil
|
||
}
|
||
ts := make([]localTargetStatus, 0, len(targets))
|
||
for _, tg := range targets {
|
||
st, _ := h.Process.Status(tg.Remote)
|
||
ts = append(ts, localTargetStatus{
|
||
Remote: tg.Remote,
|
||
RemotePort: tg.RemotePort,
|
||
WorkerState: st.State,
|
||
})
|
||
}
|
||
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)+len(topoEntries))
|
||
// Track which (local,remote,remotePort) triples are already in forwards
|
||
// so the topology-merge loop below doesn't duplicate them.
|
||
seenFwd := map[topoKey]bool{}
|
||
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
|
||
}
|
||
seenFwd[topoKey{ln.Local, ln.Remote, ln.RemotePort}] = true
|
||
forwards = append(forwards, fs)
|
||
}
|
||
// Merge cluster forwards from ring topology that aren't in the local store.
|
||
// This makes every node's status page show the FULL cluster picture, not
|
||
// just the forwards this node happens to have in its SQLite links table.
|
||
for _, t := range topoEntries {
|
||
k := topoKey{t.Local.Name, t.Remote.Name, t.Link.RemotePort}
|
||
if seenFwd[k] {
|
||
continue
|
||
}
|
||
forwards = append(forwards, forwardStatus{
|
||
Local: t.Local.Name, Remote: t.Remote.Name, RemotePort: t.Link.RemotePort,
|
||
LocalOnly: false, Kind: "remote", OwnerID: t.OwnerID, Active: t.Active,
|
||
LocalIP: t.Local.IP, LocalPort: t.Local.Port, LocalProto: t.Local.Protocol,
|
||
Group: t.Link.Group,
|
||
})
|
||
seenFwd[k] = true
|
||
}
|
||
|
||
resp := map[string]any{
|
||
"version": "0.1.0",
|
||
"workDir": h.WorkDir,
|
||
"settings": settings,
|
||
"services": locals,
|
||
"remotes": remotes,
|
||
"binaryPath": binary,
|
||
"profiles": profiles,
|
||
"localStatus": localStatuses,
|
||
"forwards": forwards,
|
||
"selfId": selfID,
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// handleClusterNodes reports registered cluster nodes and their cached frpc
|
||
// versions (M6). Used by discovery and the frontend cluster page.
|
||
func (h *Handler) handleClusterNodes(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
methodNotAllowed(w)
|
||
return
|
||
}
|
||
if h.Cluster == nil {
|
||
http.Error(w, "cluster registry not enabled", http.StatusNotFound)
|
||
return
|
||
}
|
||
// Self node goes first so a querier can identify us by Nodes[0]. The
|
||
// version we advertise is the newest locally cached frpc (falling back to
|
||
// the configured binary path), so peers can bootstrap the same version.
|
||
type nodeResp struct {
|
||
Addr string `json:"addr"`
|
||
Cache []string `json:"cache,omitempty"`
|
||
Version string `json:"version,omitempty"`
|
||
}
|
||
out := make([]nodeResp, 0, 1+len(h.Cluster.NodeList()))
|
||
ver := ""
|
||
if cs := h.Cluster.CachedVersions(); len(cs) > 0 {
|
||
ver = cs[len(cs)-1]
|
||
} else if h.BinaryPath != nil {
|
||
ver = frpcVersionOf(h.BinaryPath())
|
||
}
|
||
out = append(out, nodeResp{Addr: h.SelfAddr, Cache: h.Cluster.CachedVersions(), Version: ver})
|
||
for _, n := range h.Cluster.NodeList() {
|
||
out = append(out, nodeResp{Addr: n.Addr, Version: n.Version, Cache: n.Cache})
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"nodes": out})
|
||
}
|
||
|
||
// handleFrpcBinary serves a cached frpc binary to peer nodes over
|
||
// GET /frpc/{version}. Basic Auth is required (same creds as the manager).
|
||
func (h *Handler) handleFrpcBinary(w http.ResponseWriter, r *http.Request) {
|
||
if h.Cluster == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
name := strings.TrimPrefix(r.URL.Path, "/frpc/")
|
||
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "..") {
|
||
http.Error(w, "bad version", http.StatusBadRequest)
|
||
return
|
||
}
|
||
path, checksum, err := h.Cluster.Provide(name)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("X-Frpc-SHA256", checksum)
|
||
_, _ = io.Copy(w, f)
|
||
}
|
||
|
||
// handleClusterCache lists cached frpc versions (GET) or prunes (POST {keep:N}).
|
||
func (h *Handler) handleClusterCache(w http.ResponseWriter, r *http.Request) {
|
||
if h.Cluster == nil {
|
||
http.Error(w, "cluster registry not enabled", http.StatusNotFound)
|
||
return
|
||
}
|
||
switch r.Method {
|
||
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"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.Keep < 0 || req.Keep > 50 {
|
||
http.Error(w, "keep in [0,50]", http.StatusBadRequest)
|
||
return
|
||
}
|
||
removed := h.Cluster.PruneCache(req.Keep)
|
||
writeJSON(w, http.StatusOK, map[string]any{"removed": removed, "cache": h.Cluster.CacheInfo()})
|
||
default:
|
||
methodNotAllowed(w)
|
||
}
|
||
}
|
||
|
||
// handleClusterToken receives the circulating token (POST), lets the ring
|
||
// engine process it, returns the updated token so the caller can forward it.
|
||
func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
|
||
if h.Ring == nil {
|
||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||
return
|
||
}
|
||
if r.Method != http.MethodPost {
|
||
methodNotAllowed(w)
|
||
return
|
||
}
|
||
// Heartbeat ping: empty body (no token) is a liveness check from the
|
||
// 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
|
||
}
|
||
var tk cluster.Token
|
||
if err := json.Unmarshal(body, &tk); err != nil {
|
||
http.Error(w, "parse token: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
updated, err := h.Ring.OnToken(context.Background(), &tk)
|
||
if err != nil {
|
||
http.Error(w, "token process: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
// OnToken returns nil when it drops a stale/duplicate token: the token is
|
||
// dead, do NOT hand a nil token onward (would nil-panic in Send/Forward).
|
||
if updated == nil {
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
// Onward forwarding is ASYNC: acknowledge receipt immediately (the
|
||
// token is a relay baton, not a synchronous RPC chain). If we forwarded
|
||
// synchronously, a slow next hop would make this handler hang for the
|
||
// upstream client timeout, which would recursively stall the whole ring.
|
||
nextTK := *updated
|
||
if h.Ring.IsLeader() {
|
||
go func() { _ = h.Ring.Send(context.Background(), &nextTK) }()
|
||
} else {
|
||
go func() { _ = h.Ring.Forward(context.Background(), &nextTK) }()
|
||
}
|
||
writeJSON(w, http.StatusOK, updated)
|
||
}
|
||
|
||
// handleClusterRing reports the local ring engine state snapshot (frontend).
|
||
func (h *Handler) handleClusterRing(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
|
||
}
|
||
writeJSON(w, http.StatusOK, h.Ring.Snapshot())
|
||
}
|
||
|
||
// handleClusterJoin accepts a newcomer join request: the target node inserts
|
||
// the newcomer after itself in the ring and returns the updated ring state
|
||
// for the newcomer to adopt.
|
||
func (h *Handler) handleClusterJoin(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
methodNotAllowed(w)
|
||
return
|
||
}
|
||
if h.Ring == nil {
|
||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||
return
|
||
}
|
||
var ji cluster.JoinInfo
|
||
if err := json.NewDecoder(r.Body).Decode(&ji); err != nil {
|
||
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})
|
||
// Kick off the token cycle AFTER the newcomer has adopted (respond first,
|
||
// then start in background so the new node is no longer single when the
|
||
// token reaches it).
|
||
if wasSingle && h.Ring.IsLeader() {
|
||
go func() {
|
||
time.Sleep(800 * time.Millisecond)
|
||
h.Ring.StartRing(context.Background())
|
||
}()
|
||
}
|
||
}
|
||
|
||
// handleClusterTask accepts a new forward request (intermediate config:
|
||
// local/remote/link) and submits it to the ring as a pending task.
|
||
func (h *Handler) handleClusterTask(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
methodNotAllowed(w)
|
||
return
|
||
}
|
||
if h.Ring == nil {
|
||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||
return
|
||
}
|
||
var req struct {
|
||
Local store.Local `json:"local"`
|
||
Remote store.Remote `json:"remote"`
|
||
Link store.Link `json:"link"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "parse task: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
tk := h.Ring.SubmitTask(req.Local, req.Remote, req.Link)
|
||
writeJSON(w, http.StatusOK, map[string]any{"task": tk})
|
||
}
|
||
|
||
// handleNodeRemove publishes a node-removal command via the token; the
|
||
// target node self-removes when the command reaches it.
|
||
func (h *Handler) handleNodeRemove(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
methodNotAllowed(w)
|
||
return
|
||
}
|
||
if h.Ring == nil {
|
||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||
return
|
||
}
|
||
var req struct {
|
||
ID string `json:"id"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.ID == "" {
|
||
http.Error(w, "node id required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
tk := h.Ring.RemoveNode(req.ID)
|
||
writeJSON(w, http.StatusOK, map[string]any{"task": tk})
|
||
}
|
||
|
||
// handleClusterCreate reseeds this node as a fresh standalone leader (the
|
||
// runtime "创建集群" path). Refuses with 409 if this node is still a
|
||
// multi-node member — reseeding mid-cluster would split the ring.
|
||
func (h *Handler) handleClusterCreate(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
methodNotAllowed(w)
|
||
return
|
||
}
|
||
if h.Ring == nil {
|
||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err := h.Ring.CreateCluster(); err != nil {
|
||
http.Error(w, err.Error(), http.StatusConflict)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, h.Ring.Snapshot())
|
||
}
|
||
|
||
// handleClusterJoinRing is the runtime "加入集群" path: this node joins the
|
||
// cluster at the given peer address (newcomer-side — it POSTs its own
|
||
// JoinInfo to the peer's /cluster/join and adopts the returned state).
|
||
// Synchronous so the UI gets a real success/failure; capped at 10s (the
|
||
// peer dial itself times out at 8s). Refuses 409 if already a multi-node
|
||
// member (would split the ring).
|
||
func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
methodNotAllowed(w)
|
||
return
|
||
}
|
||
if h.Ring == nil {
|
||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||
return
|
||
}
|
||
var req struct {
|
||
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)
|
||
return
|
||
}
|
||
if req.Addr == "" {
|
||
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, req.JoinKey); err != nil {
|
||
http.Error(w, "join failed: "+err.Error(), http.StatusBadGateway)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, h.Ring.Snapshot())
|
||
}
|
||
|
||
// handleClusterJoin accepts a newcomer join request: the target node inserts
|
||
// the newcomer after itself in the ring and returns the updated ring state
|
||
// for the newcomer to adopt.
|
||
// .../bin/frpc-0.71.0/frpc. Empty when not a versioned cache path.
|
||
func frpcVersionOf(binPath string) string {
|
||
dir := filepath.Dir(binPath)
|
||
base := filepath.Base(dir)
|
||
if strings.HasPrefix(base, "frpc-") {
|
||
return strings.TrimPrefix(base, "frpc-")
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// connStateOf derives the frps connection state from the local worker process
|
||
// state plus a probe of its log. We never control the remote frps: the worker is
|
||
// our local frpc; "connected" means frpc has successfully logged in to frps.
|
||
// - disabled -> remote disabled, worker not started
|
||
// - running+login -> frpc logged in to frps (connected)
|
||
// - running -> frpc up but not yet logged in (connecting)
|
||
// - starting/restarting -> connecting
|
||
// - crashed/exit nonzero -> failed
|
||
// - stopped clean / no worker -> not_started
|
||
func connStateOf(workerLog string, enabled bool, st process.Status) string {
|
||
if !enabled {
|
||
return "disabled"
|
||
}
|
||
switch st.State {
|
||
case "running":
|
||
tail, _ := tailFile(workerLog, 64*1024)
|
||
if tail != "" {
|
||
// frpc prints "login to server success" once the control link is up.
|
||
if strings.Contains(tail, "login to server success") ||
|
||
strings.Contains(tail, "start proxy success") {
|
||
return "connected"
|
||
}
|
||
if strings.Contains(tail, "login to server error") ||
|
||
strings.Contains(tail, "connect to server error") ||
|
||
strings.Contains(tail, "connect server error") {
|
||
return "failed"
|
||
}
|
||
}
|
||
return "connecting"
|
||
case "starting", "restarting":
|
||
return "connecting"
|
||
case "crashed":
|
||
return "failed"
|
||
case "stopped":
|
||
if st.ExitCode != 0 || st.Err != "" {
|
||
return "failed"
|
||
}
|
||
return "not_started"
|
||
default:
|
||
return "not_started"
|
||
}
|
||
}
|
||
|
||
// fetchProxyStates queries a worker's local frpc admin API (webServer) for
|
||
// true per-proxy status: GET /api/status returns a map keyed by proxy type,
|
||
// each value a list of { name, type, status, err, local_addr, remote_addr }.
|
||
// status is one of frpc's proxy phases:
|
||
//
|
||
// new | wait start | start error | running | check failed | closed
|
||
func fetchProxyStates(addr string, port int, user, pass string) ([]proxyState, error) {
|
||
if addr == "" {
|
||
addr = "127.0.0.1"
|
||
}
|
||
url := fmt.Sprintf("http://%s:%d/api/status", addr, port)
|
||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.SetBasicAuth(user, pass)
|
||
cli := &http.Client{Timeout: 3 * time.Second}
|
||
resp, err := cli.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
_, _ = io.Copy(io.Discard, resp.Body)
|
||
return nil, fmt.Errorf("admin status HTTP %d", resp.StatusCode)
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var m map[string][]proxyState
|
||
if err := json.Unmarshal(body, &m); err != nil {
|
||
return nil, err
|
||
}
|
||
var out []proxyState
|
||
for _, arr := range m {
|
||
out = append(out, arr...)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// connStateAdmin derives the overall worker connection state from the per-proxy
|
||
// states reported by the local frpc admin API.
|
||
func connStateAdmin(states []proxyState) string {
|
||
if len(states) == 0 {
|
||
return "connecting"
|
||
}
|
||
anyRunning := false
|
||
for _, s := range states {
|
||
switch s.Status {
|
||
case "running":
|
||
anyRunning = true
|
||
case "wait start":
|
||
return "connecting"
|
||
}
|
||
}
|
||
if anyRunning {
|
||
return "connected"
|
||
}
|
||
return "failed"
|
||
}
|
||
|
||
// groupCountOf returns the number of distinct non-empty LB groups a remote
|
||
// participates in, by mapping each forward's service back to its local.
|
||
func groupCountOf(locals []store.Local, fwd []store.Forward) int {
|
||
groupByLocal := map[string]string{}
|
||
for _, l := range locals {
|
||
if l.LBGroup != "" {
|
||
groupByLocal[l.Name] = l.LBGroup
|
||
}
|
||
}
|
||
seen := map[string]bool{}
|
||
for _, f := range fwd {
|
||
if g := groupByLocal[f.Service]; g != "" {
|
||
seen[g] = true
|
||
}
|
||
}
|
||
return len(seen)
|
||
}
|
||
|
||
func methodNotAllowed(w http.ResponseWriter) {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(code)
|
||
_ = fmtJSONEncode(w, v)
|
||
}
|
||
|
||
func fmtJSONEncode(w http.ResponseWriter, v any) error {
|
||
return json.NewEncoder(w).Encode(v)
|
||
}
|