mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
- 主题: sakura×frost 玻璃拟态 (theme.css) + SCSS 变量重映射 - 侧栏: 玻璃侧栏 246px + 渐变品牌区 + 面包屑导航 - 状态页: 玻璃 KPI 卡 + 远程节点/本地服务卡片网格 - 集群页: 英雄玻璃卡 + 横向环拓扑链 + 待办命令/活跃拓扑/日志区 - 令牌环: 新增 lastSync 上次同步时间替代周期计数 - 模拟 frps: frps2/frps3 容器 + test-forward.sh 全链路验证脚本 - 修复: BinaryPath 空导致 worker 不启动, 撤销仅撤第一个 link, 任务复活风暴 (published 追踪)
722 lines
23 KiB
Go
722 lines
23 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()
|
|
|
|
auth := func(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
u, p, ok := r.BasicAuth()
|
|
if !ok || u != h.User || p != h.Password {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// API routes (basic auth).
|
|
mux.HandleFunc(apiPrefix+"/status", auth(h.handleStatus))
|
|
mux.HandleFunc(apiPrefix+"/canvas", auth(h.handleCanvasSave))
|
|
mux.HandleFunc(apiPrefix+"/settings", auth(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPut {
|
|
h.handleSettingsPut(w, r)
|
|
return
|
|
}
|
|
h.handleSettingsGet(w, r)
|
|
}))
|
|
mux.HandleFunc(apiPrefix+"/binary/status", auth(h.handleBinaryStatus))
|
|
mux.HandleFunc(apiPrefix+"/binary/install", auth(h.handleBinaryInstall))
|
|
|
|
// Profile lifecycle routes.
|
|
mux.HandleFunc(apiPrefix+"/profiles/", auth(h.handleProfile))
|
|
mux.HandleFunc(apiPrefix+"/remotes", auth(h.handleRemoteUpsert))
|
|
mux.HandleFunc(apiPrefix+"/remotes/", auth(h.handleRemoteDelete))
|
|
|
|
// M6: cluster nodes + per-node cached versions (UI + discovery).
|
|
mux.HandleFunc(apiPrefix+"/cluster/nodes", auth(h.handleClusterNodes))
|
|
mux.HandleFunc(apiPrefix+"/cluster/cache", auth(h.handleClusterCache))
|
|
mux.HandleFunc(apiPrefix+"/cluster/token", auth(h.handleClusterToken))
|
|
mux.HandleFunc(apiPrefix+"/cluster/ring", auth(h.handleClusterRing))
|
|
mux.HandleFunc(apiPrefix+"/cluster/join", auth(h.handleClusterJoin))
|
|
mux.HandleFunc(apiPrefix+"/cluster/task", auth(h.handleClusterTask))
|
|
mux.HandleFunc(apiPrefix+"/cluster/node-remove", auth(h.handleNodeRemove))
|
|
mux.HandleFunc(apiPrefix+"/cluster/create", auth(h.handleClusterCreate))
|
|
mux.HandleFunc(apiPrefix+"/cluster/join-ring", auth(h.handleClusterJoinRing))
|
|
|
|
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
|
|
// Not under /api so peers hit it directly; auth still applied.
|
|
mux.HandleFunc("/frpc/", auth(h.handleFrpcBinary))
|
|
|
|
// Static assets (also basic auth) under /.
|
|
// The SPA shell is served behind the SAME Basic Auth as the API (plan:
|
|
// "healthz 免认证,其余 Basic Auth"). Without this, the browser loads the
|
|
// page without a 401 challenge, never caches credentials, and every
|
|
// same-origin /api/* fetch (credentials:"same-origin") gets 401 — so the
|
|
// SPA renders but all data pages read as empty ("集群未启动"). Wrapping /
|
|
// in auth makes the browser prompt once, cache the Basic header for the
|
|
// origin, and send it on every subsequent asset + API request.
|
|
mux.HandleFunc("/", auth(h.handleStatic))
|
|
|
|
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"`
|
|
}
|
|
|
|
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).
|
|
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})
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"version": "0.1.0",
|
|
"workDir": h.WorkDir,
|
|
"settings": settings,
|
|
"services": locals,
|
|
"remotes": remotes,
|
|
"binaryPath": binary,
|
|
"profiles": profiles,
|
|
"localStatus": localStatuses,
|
|
}
|
|
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:
|
|
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 — answer 200 without processing.
|
|
if r.Body == nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
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
|
|
}
|
|
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"`
|
|
}
|
|
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 h.Ring.IsMember() {
|
|
http.Error(w, "already a multi-node cluster member; leave first", http.StatusConflict)
|
|
return
|
|
}
|
|
jc, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
if err := h.Ring.JoinRingAddr(jc, req.Addr); err != nil {
|
|
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)
|
|
}
|