Files
webui4frpc/internal/httpapi/server.go
JianFeeeee c90d4f3791 feat(cluster,webapi): 集群页可切换负载均衡模式(每节点一个开关)
## 为什么之前没有开关(以及为什么现在这么加)

模式原本只读一次环境变量 `W4F_BALANCE_MODE`,UI 只显示「观察模式 / 已启用」。
把开关加进来时有两个坑必须一起解决,否则开关是个摆设:

1. **不持久化就等于骗人**:只用 env,页面上翻一下、重启一次就回到默认,
   而页面已经显示成功了。
2. **不能做成集群级开关**:每个成员只释放自己的转发,根本没有一个动作能把它们
   一起翻过来。做成集群开关就是撒谎。

所以:**每节点一个开关**,写的是本节点自己的设置。

## 持久化与优先级

- 新增 `settings` 表的 `balance_mode` 列 + `Store.BalanceMode()`。**单独一列**,
  不是塞进 settings JSON:那个 JSON 是整份重写的,而这是会被各节点同时写、必须
  各自收敛的状态,跟 `nodeKey`/`settings` 同一类处理。
- **存了就以存的为准**,env 只作首次启动的默认值;`Settings()` 读回时带上它,
  但 `UpdateSettings` 只在非空时镜像到列,所以一次无关的设置保存不会把它清掉。
- `SetModeFromUI` **先落盘再应用**:写失败就什么都不改,否则节点会运行一个重启后
  复现不出来的模式——开关就成了谎言。
- 认不出的模式一律拒绝(400),不猜。

## 报告新增两个字段

- `modeSource`: `env` | `stored` —— 说清当前模式是启动参数给的还是有人在这里选的。
- `canControl`: 由**调用者的权限**决定,只读身份不会看到一个点了会 403 的开关。

`PUT /cluster/balance/mode` 走 **write** 级鉴权:释放会停 worker、断活连接。

## 两处我自己抓到并修掉的错

1. **`ModeSource()` 一开始写成「能存就算 stored」**(判据是 `persist != nil`),
   于是**每个有 store 的节点都自称 stored**,哪怕从没人设过。canary 第一轮就露了:
   `mode=dry-run source=stored`。改成看「是否真的存过值」。
2. **启动日志打的是 env 变量而不是生效值**,存过 `on` 之后仍打 `DRY-RUN`——
   上一行的 `loaded from settings` 还在,两行自相矛盾,正好会让人以为释放是关的
   而它其实开着。

## 测试

新增 6 条:存的模式压过 env、没存就回落 env、落盘失败则模式不变、乱值被拒、
UI 切换同时落盘并生效、无持久化能力时不宣称可控制。
canary 实跑 6 步:初始 `dry-run/env` → 设 `on` → 复查 `on/stored` → 乱值 400 →
乱值后模式不变 → **重启后仍 `on/stored`**,启动日志 `load balancing ON (source=stored …)`。
vue-tsc 干净;go build / vet / go test ./... 全绿。
2026-10-06 14:37:18 +08:00

1010 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package httpapi serves the web UI and REST API.
package httpapi
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"webui4frpc/internal/cluster"
"webui4frpc/internal/process"
"webui4frpc/internal/store"
"webui4frpc/internal/version"
)
//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
// Sessions issues the UI login cookies (see session.go).
Sessions *SessionStore
// 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
// Balancer is optional: when nil the balance endpoint reports 404.
Balancer *cluster.Balancer
// 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+"/cluster/balance", h.auth("read")(h.handleClusterBalance))
mux.HandleFunc(apiPrefix+"/cluster/balance/mode", h.auth("write")(h.handleClusterBalanceMode))
mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs))
mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport))
// Audit exports (CSV): read-level — exporting evidence is exactly what a
// viewer/auditor role exists for. RFC4180 CSV, ISO8601 UTC timestamps.
mux.HandleFunc(apiPrefix+"/audit/users.csv", h.auth("read")(h.handleAuditUsersCsv))
mux.HandleFunc(apiPrefix+"/audit/apikeys.csv", h.auth("read")(h.handleAuditApiKeysCsv))
mux.HandleFunc(apiPrefix+"/audit/cluster-log.csv", h.auth("read")(h.handleAuditClusterLogCsv))
mux.HandleFunc(apiPrefix+"/audit/worker-logs.csv", h.auth("read")(h.handleAuditWorkerLogsCsv))
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))
// UI session login/logout. Deliberately NOT behind auth(): login must be
// reachable without credentials, and logout must work even when the
// session is already gone.
mux.HandleFunc(apiPrefix+"/login", h.handleLogin)
mux.HandleFunc(apiPrefix+"/logout", h.handleLogout)
mux.HandleFunc(apiPrefix+"/users", h.auth("superadmin")(h.handleUsers))
mux.HandleFunc(apiPrefix+"/users/", h.auth("superadmin")(h.handleUserByName))
mux.HandleFunc(apiPrefix+"/apikeys", h.auth("superadmin")(h.handleApiKeys))
mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("superadmin")(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 WITHOUT auth: the SPA must load before it can show its
// login form (auth.ts resolves GET /me on mount; a 401 there drops the UI
// into the login page). Gating index.html behind auth() would make an
// unauthenticated browser see {"error":"unauthorized"} instead of the
// app shell — exactly the bug where the domain showed raw JSON. The
// embedded assets are static/public (no user data); every privileged
// action still requires an authenticated API call.
mux.HandleFunc("/", 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.RemoteStatus(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.
// In the per-forward model multiple workers may exist for one remote;
// use the first worker's log when available.
if p.AdminEnabled && len(p.ProxyStates) > 0 {
p.ConnState = connStateAdmin(p.ProxyStates)
} else {
p.ConnState = connStateOf(firstWorkerLog(h.Process, rv.Name), rv.Enabled, st)
}
profiles = append(profiles, p)
}
// Local status: localOnly forwards that stay on THIS node — each one plus
// its forwarding target and whether the per-forward worker is healthy.
// Cluster (non-localOnly) forwards are NOT listed here: their lifecycle is
// managed by the ring and shown in the forwards card grid above. Listing
// cluster forwards here as "本地服务" was a leftover from the per-remote
// model and misled users into seeing cluster forwards as local ones.
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 {
if !l.LocalOnly {
continue
}
targets, err := h.Store.LinksForLocal(l.Name)
if err != nil {
targets = nil
}
ts := make([]localTargetStatus, 0, len(targets))
for _, tg := range targets {
// Per-forward model: this target's worker is the specific
// (local,remote,port) forward; active = its own process running.
st, has := h.Process.Status(process.WorkerKey(l.Name, tg.Remote, tg.RemotePort))
state := "stopped"
if has {
state = st.State
}
ts = append(ts, localTargetStatus{
Remote: tg.Remote,
RemotePort: tg.RemotePort,
WorkerState: 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
// Per-forward model: check this exact forward's worker process.
if st, has := h.Process.Status(process.WorkerKey(ln.Local, ln.Remote, ln.RemotePort)); 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": version.Version,
"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上邻居意识到当前环已经没有节点了".
//
// 同时,心跳到达说明前驱还活着,leader 应在地自己的拓扑中把它标记为
// alive。否则 forwardToNext 一次超时把前驱 MarkOffline 后就再没有
// 途径恢复它——前驱明明活着,leader 却认为它 offline → AliveSuccessor
// 找不到后继,令牌冻结在 leader 手上。心跳是 leader 侧纠正误判的唯一
// 入口(因为心跳只有前驱发给 leader 这一条边)。
if r.Body == nil {
if s := h.Ring.State(); len(s.Nodes) <= 1 {
http.Error(w, "standalone node", http.StatusConflict)
return
}
// 心跳到达 = 前驱活着,标记 alive(纠正 forwardToNext 的误判)
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
s := h.Ring.State()
for i := range s.Nodes {
if h, _, e := net.SplitHostPort(s.Nodes[i].Addr); e == nil && h == host {
if !s.Nodes[i].Alive {
log.Printf("heartbeat from %s revives it (was offline)", s.Nodes[i].ID)
}
s.Nodes[i].Alive = true
s.Nodes[i].LastSeen = time.Now().Unix()
break
}
}
}
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
}
// 同上:心跳到达 = 前驱活着
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
s := h.Ring.State()
for i := range s.Nodes {
if h, _, e := net.SplitHostPort(s.Nodes[i].Addr); e == nil && h == host {
if !s.Nodes[i].Alive {
log.Printf("heartbeat from %s revives it (was offline)", s.Nodes[i].ID)
}
s.Nodes[i].Alive = true
s.Nodes[i].LastSeen = time.Now().Unix()
break
}
}
}
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())
}
// handleClusterBalance reports the load balancer's read-only view: per-node
// scores, whether anything is over the threshold, and the migration it would
// perform. Publishing nothing is the point — an operator asking "is this cluster
// balanced?" currently gets silence from the log in the healthy case, which is
// indistinguishable from balancing being switched off.
func (h *Handler) handleClusterBalance(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
}
if h.Balancer == nil {
http.Error(w, "load balancer not enabled", http.StatusNotFound)
return
}
rep := h.Balancer.Report()
// CanControl is decided by the caller's own level rather than by the
// presence of a stored value, so a read-only principal is never shown a
// switch that would 403.
rep.CanControl = hasLevel(r, "write")
writeJSON(w, http.StatusOK, rep)
}
// handleClusterBalanceMode switches load balancing on/off at runtime.
//
// It is per-node on purpose. Every member decides about its OWN forwards, so the
// switch belongs to each node: an operator may well want to turn releasing on
// everywhere, but the server should not pretend those are one atomic action.
// (A cluster-wide control can be layered on top later; the primitive here is the
// one that actually has an owner.)
//
// Write-level auth: releasing stops a worker and drops a live connection.
func (h *Handler) handleClusterBalanceMode(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if h.Balancer == nil {
http.Error(w, "load balancer not enabled", http.StatusNotFound)
return
}
var req struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if err := h.Balancer.SetModeFromUI(req.Mode); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, h.Balancer.Report())
}
// 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 ""
}
// firstWorkerLog returns the log path of the first per-forward worker of a
// remote (worker keys are "local~remote~port"), or "" when none exist. Used
// by status aggregation to infer frps connectivity from worker logs.
func firstWorkerLog(pm *process.Manager, remote string) string {
for _, key := range pm.RemoteWorkers(remote) {
return pm.LogPath(key)
}
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)
}