Files
webui4frpc/internal/httpapi/server.go
JianFeeeee c69fb2caba feat(cluster): 负载均衡改为每节点自主「先撤销本地转发,再丢回环」
按用户要求重做:不再由 leader 单独调度,而是**每个节点对自己的转发跑同一条
规则**,超了就自己撤销一条、把任务丢回环上,由环原有的「最闲者认领」决定它
落到哪台。

## 为什么必须是 owner 自己动手(不是洁癖,是实测逼出来的)

真机探针(跑完已删)显示,真实 revoke 之后:

    TopologyOwner after revoke = "n1"     ← 条目还在(只标记不删除)
    n1 Forwards=3  score=310.0            ← 负载根本没降
    after step: revoking=true publishing=false

⇒ 一节点的负载**只有它自己的拓扑条目消失时才会下降**,而条目是本地簿记,没有任何
对端可以代它写。leader 从外面只能看见失衡、无法让失衡成为事实——它必须伸手去改
另一个节点的簿记,而这正是这个环到处都在避免的跨节点写(revoke / restart 定向往
owner 也是同一个理由)。

⇒ 而且旧实现**根本跑不起来**:phase 1 判据是「条目消失」,可 mark-don't-remove
让它永远不消失,于是每 120s 打一条 `revoking … timed out; abandoning`,
**phase 2 永不执行,一条转发都不会迁**。开 `W4F_BALANCE_MODE=on` 只会刷日志。

## 规则(替代原来的 ratio 阈值)

    当 Forwards >= 2 且 Forwards > ceil(总数/存活节点数) 时,撤销一条

- **自限**:3 条 / 3 台 → owner 2 > 1,卸一条 → 1,`1 > 1` 不成 → 停。
  没有上界的话 owner 会把转发**全部卸光**。
- **不会卸空**:低于下限(2)永不撤销。
- 平摊值向上取整(3/3=1,不是 0),否则 1 条 / 3 台的节点也算「超了」。
- **不再用 mem/net 参与判据**:实测 `.30` 空闲时 netPct 能到 55%,Score 会高于
  有转发的节点——用 Score 会把纯网络抖动当成负载。
- 节奏用**每节点冷却**(默认 600s)而不是「失衡持续多久」窗口:规则本身已经要求
  真实失衡,没有需要平滑的瞬态,只有需要限制的抖动频率。

## 为什么必须是新的 Release 任务类型,不能复用 Revoke

用户停止(Revoke)**保留拓扑条目**作为 disabled 标志的载体;均衡释放必须**删除
条目**,因为 (a) 负载由自己的条目数算出,条目不删负载不降;(b) 每个节点的
duplicate-guard 会拒绝「已有 owner 的转发」的创建任务——保留条目会让**所有节点
都拒领**,转发被永久搁死在刚放弃它的那台机器上。

Release 因此:`Handler.Release`(停 worker,但**不标记 stopped**)→ 删条目 →
`SubmitTask` 以**启用状态**重新发布。它需要自己的 Handler 方法,不能回落到
Revoke:Revoke 会顺手把 link 标成 stopped,而这条转发即将在别的节点上跑。

## 顺带修掉两个既有缺口(实测确认)

1. **`submitMigrate` 发布不出去**:`OnToken` 的 localPending 重合并只保留「从未
   publish 过」的本地 pending,而只有 `Add*` 系列会记 `published`。引擎里直接写
   `PendingTasks` 的任务会在下一次 adopt 时被静默丢弃。
   ⇒ 新 `ReleaseTask` 走 `AddPending`。
   (`SubmitRestart` 有同样的毛病,但它不在本次范围,未改动——见提交说明末尾。)
2. `Release` 需要绕过 duplicate-claim 守卫(同 Restart),否则「owner 本来就持有
   这条转发」这一前提会让每条 Release 都被丢掉。

## 测试(17 条,全绿)

规则:平摊值取整、超了才撤、撤到平摊就停、单节点不撤、低于下限不撤、不撤已停
转发、候选选择确定。
机制:Revoke 留载体 vs Release 删条目(这是两种语义的核心差异)、Release 只由
owner 执行且不消费他人的任务、owner 释放后环的普通认领把它接走。
模式:dry-run 不动任何东西、off 真的什么都不做、未知模式回落 dry-run、
冷却限制抖动频率、确认超时清理。
只读:Report 不变更任何状态、能解释「超了但不动」的原因、均衡节点给出真实答案。

**双向验证 3 条**(改坏→变红→还原→变绿):
- 去掉 Release 的 duplicate-guard 例外 → `the owner released 0 times, want 1`
- 去掉「不超过平摊值」上界 → `2 owned against share 2 must not release`
- Release 改成只标记不删除 → `n1 should now own 2, got 3`

go build / go vet / go test ./... 全绿(go clean -testcache 后逐包实跑)。

## 未做 / 待办

- `SubmitRestart` 的同类发布缺口未修(同样会被 adopt 丢弃),需要单独确认它
  在实机上是否真的丢过 restart。
- UI 尚未跟进新报告结构(下一步)。
2026-10-06 14:03:03 +08:00

972 lines
34 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+"/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
}
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)
}