// Package httpapi serves the web UI and REST API. package httpapi import ( "bytes" "context" "embed" "encoding/json" "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "strings" "time" "webui4frpc/internal/cluster" "webui4frpc/internal/process" "webui4frpc/internal/store" ) //go:embed all:dist var distFS embed.FS // Handler bundles dependencies for the HTTP API. type Handler struct { Store *store.Store Process *process.Manager WorkDir string BinDir string User string Password string // InstallBinary downloads and activates a frpc binary. Set by the app to // avoid an import cycle with the install package. InstallBinary func(version string) (path, ver string, err error) // BinaryPath resolves the current worker binary. BinaryPath func() string // RunInstall is a hook to trigger canary tasks after canvas save. SyncWorkers func() // Cluster is the peer-to-peer binary registry (M6). Nil disables M6 routes. Cluster *cluster.Registry // Ring is the token-ring engine (M6). Nil disables ring routes. Ring *cluster.Engine // SelfAddr is this node's reachable listen address (from -addr), used for // cluster discovery so peers can reach back for binary exchange. SelfAddr string } const ( healthzPath = "/healthz" apiPrefix = "/api/manager" ) // NewServeMux builds the full HTTP handler. func NewServeMux(h *Handler) (http.Handler, error) { mux := http.NewServeMux() mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) // read tier (viewer + read-scope key + everyone above): pure-GET reads. // Routes that also accept a mutating method re-check the write level inside // the handler so a viewer GET still works while viewer PUT/POST is 403. mux.HandleFunc(apiPrefix+"/status", h.auth("read")(h.handleStatus)) mux.HandleFunc(apiPrefix+"/canvas", h.auth("read")(h.handleCanvasSave)) // GET read; PUT write-checked inside mux.HandleFunc(apiPrefix+"/canvas/export", h.auth("read")(h.handleCanvasExport)) mux.HandleFunc(apiPrefix+"/canvas/import", h.auth("write")(h.handleCanvasImport)) mux.HandleFunc(apiPrefix+"/locals", h.auth("write")(h.handleLocals)) mux.HandleFunc(apiPrefix+"/locals/", h.auth("write")(h.handleLocalByName)) mux.HandleFunc(apiPrefix+"/links", h.auth("write")(h.handleLinks)) mux.HandleFunc(apiPrefix+"/links/", h.auth("write")(h.handleLinkByID)) // Per-forward start/stop + one-click group start/stop. Decoupled from the // remote-node worker controls so the forwards page owns forward lifecycle // (local-only → local worker restart; cluster → ring submit/revoke). mux.HandleFunc(apiPrefix+"/forwards/start", h.auth("write")(h.handleForwardsStart)) mux.HandleFunc(apiPrefix+"/forwards/stop", h.auth("write")(h.handleForwardsStop)) mux.HandleFunc(apiPrefix+"/forwards/group/start", h.auth("write")(h.handleForwardsGroupStart)) mux.HandleFunc(apiPrefix+"/forwards/group/stop", h.auth("write")(h.handleForwardsGroupStop)) mux.HandleFunc(apiPrefix+"/forwards/assign", h.auth("write")(h.handleForwardsAssign)) mux.HandleFunc(apiPrefix+"/forwards/group/delete", h.auth("write")(h.handleForwardsGroupDelete)) mux.HandleFunc(apiPrefix+"/settings", h.auth("read")(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPut && !hasLevel(r, "write") { forbidden(w) return } if r.Method == http.MethodPut { h.handleSettingsPut(w, r) return } h.handleSettingsGet(w, r) })) mux.HandleFunc(apiPrefix+"/binary/status", h.auth("read")(h.handleBinaryStatus)) mux.HandleFunc(apiPrefix+"/binary/install", h.auth("write")(h.handleBinaryInstall)) // Profile lifecycle: status/config/logs = read; start/stop/restart = write // (write-checked inside handleProfile). mux.HandleFunc(apiPrefix+"/profiles/", h.auth("read")(h.handleProfile)) // write tier (write-scope key / admin): single-resource mutation. mux.HandleFunc(apiPrefix+"/remotes", h.auth("write")(h.handleRemoteUpsert)) mux.HandleFunc(apiPrefix+"/remotes/", h.auth("write")(h.handleRemoteDelete)) // M6 cluster: reads + control plane. Inter-node token relay uses Basic flag // creds which resolve to admin via the flag fast path, so write level keeps // the ring working while blocking read-scope keys from injecting tasks. mux.HandleFunc(apiPrefix+"/cluster/nodes", h.auth("read")(h.handleClusterNodes)) mux.HandleFunc(apiPrefix+"/cluster/cache", h.auth("read")(h.handleClusterCache)) // GET read; POST write-checked inside mux.HandleFunc(apiPrefix+"/cluster/ring", h.auth("read")(h.handleClusterRing)) mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs)) mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport)) mux.HandleFunc(apiPrefix+"/cluster/token", h.auth("write")(h.handleClusterToken)) mux.HandleFunc(apiPrefix+"/cluster/join", h.auth("write")(h.handleClusterJoin)) mux.HandleFunc(apiPrefix+"/cluster/task", h.auth("write")(h.handleClusterTask)) mux.HandleFunc(apiPrefix+"/cluster/node-remove", h.auth("write")(h.handleNodeRemove)) mux.HandleFunc(apiPrefix+"/cluster/create", h.auth("write")(h.handleClusterCreate)) mux.HandleFunc(apiPrefix+"/cluster/join-ring", h.auth("write")(h.handleClusterJoinRing)) // Account & API key management (admin only). mux.HandleFunc(apiPrefix+"/me", h.auth("read")(h.handleMe)) mux.HandleFunc(apiPrefix+"/users", h.auth("admin")(h.handleUsers)) mux.HandleFunc(apiPrefix+"/users/", h.auth("admin")(h.handleUserByName)) mux.HandleFunc(apiPrefix+"/apikeys", h.auth("admin")(h.handleApiKeys)) mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("admin")(h.handleApiKeyByID)) // M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds). // Not under /api so peers hit it directly; auth still applied. Peers // resolve to admin via the flag fast path. mux.HandleFunc("/frpc/", h.auth("read")(h.handleFrpcBinary)) // Static assets behind the same auth as the API: the browser caches the // Basic header once and sends it on every asset + /api/* request, so the // SPA loads for any valid identity (viewer included). mux.HandleFunc("/", h.auth("read")(h.handleStatic)) return mux, nil } // handleStatic serves the embedded web build. Paths map to dist files; / and // unknown paths serve index.html for SPA routing. The SPA uses hash-based // routing, so returning index.html for "/" is enough — a redirect here would // loop (the hash fragment never reaches the server). func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { sub, err := fs.Sub(distFS, "dist") if err != nil { http.Error(w, "assets unavailable", http.StatusInternalServerError) return } name := strings.TrimPrefix(r.URL.Path, "/") if name == "" { name = "index.html" } data, err := fs.ReadFile(sub, name) if err != nil { // SPA fallback: any non-file path serves index.html. data, err = fs.ReadFile(sub, "index.html") if err != nil { http.NotFound(w, r) return } name = "index.html" } if ct := contentTypeFor(name); ct != "" { w.Header().Set("Content-Type", ct) } _, _ = w.Write(data) } func contentTypeFor(name string) string { switch { case strings.HasSuffix(name, ".html"): return "text/html; charset=utf-8" case strings.HasSuffix(name, ".js"): return "application/javascript" case strings.HasSuffix(name, ".css"): return "text/css" case strings.HasSuffix(name, ".svg"): return "image/svg+xml" case strings.HasSuffix(name, ".png"): return "image/png" case strings.HasSuffix(name, ".ico"): return "image/x-icon" default: return "" } } // proxyState is a per-proxy status row reported by the local frpc admin // API (GET /api/status). Status is one of frpc's proxy phases: // new | wait start | start error | running | check failed | closed. type proxyState struct { Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` Err string `json:"err,omitempty"` LocalAddr string `json:"local_addr"` RemoteAddr string `json:"remote_addr"` } func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w) return } locals, _ := h.Store.ListLocals() remotes, _ := h.Store.ListRemotes() settings, _ := h.Store.Settings() type profileStatus struct { Name string `json:"name"` Enabled bool `json:"enabled"` Status process.Status `json:"process"` HasProc bool `json:"hasProcess"` Forwards []store.Forward `json:"forwards"` // ConnState is the derived frps connection state: // connected | connecting | failed | not_started | disabled. ConnState string `json:"connState"` // AdminEnabled is true when the remote configures a local frpc admin // API (adminPort > 0); then ProxyStates reflects real proxy health. AdminEnabled bool `json:"adminEnabled"` ProxyStates []proxyState `json:"proxyStates,omitempty"` // GroupCount is the number of distinct LB groups across this remote's // forwards (M3 display). GroupCount int `json:"groupCount,omitempty"` } // Build per-forward lookup tables: a localByName index for the forwards // array, and an ownerOf map from ring topology (keyed by the // local/remote/remotePort triple) so each forward card can show its owning // node and active state. plan §任务撤销 / §令牌环协议. localByName := make(map[string]store.Local, len(locals)) for _, l := range locals { localByName[l.Name] = l } type topoKey struct{ local, remote string; port int } ownerOf := map[topoKey]string{} selfID := h.SelfAddr if h.Ring != nil { snap := h.Ring.Snapshot() selfID = snap.SelfID for _, e := range snap.Topology { k := topoKey{e.Local.Name, e.Remote.Name, e.Link.RemotePort} if _, ok := ownerOf[k]; !ok { ownerOf[k] = e.OwnerID } } } profiles := make([]profileStatus, 0, len(remotes)) binary := "" if h.BinaryPath != nil { binary = h.BinaryPath() } for _, rv := range remotes { st, has := h.Process.Status(rv.Name) fwd, _ := h.Store.LinksForRemote(rv.Name) p := profileStatus{ Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd, AdminEnabled: rv.AdminPort > 0, GroupCount: groupCountOf(locals, fwd), } if rv.AdminPort > 0 { // Query the local frpc admin API for true per-proxy state. if states, err := fetchProxyStates(rv.AdminAddr, rv.AdminPort, rv.AdminUser, rv.AdminPassword); err == nil { p.ProxyStates = states } } // Prefer admin-derived conn state; fall back to log inference. if p.AdminEnabled && len(p.ProxyStates) > 0 { p.ConnState = connStateAdmin(p.ProxyStates) } else { p.ConnState = connStateOf(h.Process.LogPath(rv.Name), rv.Enabled, st) } profiles = append(profiles, p) } // Local status: each local plus its forwarding targets and whether each // target's worker is healthy (running). The per-forward owner/active detail // lives in the forwards array below; this is the local-services overview. type localTargetStatus struct { Remote string `json:"remote"` RemotePort int `json:"remotePort"` WorkerState string `json:"workerState"` } type localStatus struct { Local store.Local `json:"local"` Targets []localTargetStatus `json:"targets"` } localStatuses := make([]localStatus, 0, len(locals)) for _, l := range locals { targets, err := h.Store.LinksForLocal(l.Name) if err != nil { targets = nil } ts := make([]localTargetStatus, 0, len(targets)) for _, tg := range targets { st, _ := h.Process.Status(tg.Remote) ts = append(ts, localTargetStatus{ Remote: tg.Remote, RemotePort: tg.RemotePort, WorkerState: st.State, }) } localStatuses = append(localStatuses, localStatus{Local: l, Targets: ts}) } // Forwards: one entry per link — the forward-centric view the UI renders as // cards. kind distinguishes 本地转发 (localOnly) from 远程转发 (cluster- // distributed); ownerId/active are resolved from ring topology (remote) or // the local worker (localOnly); group drives one-click group start/stop. type forwardStatus struct { Local string `json:"local"` Remote string `json:"remote"` RemotePort int `json:"remotePort"` LocalOnly bool `json:"localOnly"` Kind string `json:"kind"` // "local" | "remote" OwnerID string `json:"ownerId,omitempty"` Active bool `json:"active"` Disabled bool `json:"disabled"` Group string `json:"group,omitempty"` LocalIP string `json:"localIp,omitempty"` LocalPort int `json:"localPort,omitempty"` LocalProto string `json:"localProto,omitempty"` } links, _ := h.Store.ListLinks() forwards := make([]forwardStatus, 0, len(links)) for _, ln := range links { loc, ok := localByName[ln.Local] if !ok { continue } fs := forwardStatus{ Local: ln.Local, Remote: ln.Remote, RemotePort: ln.RemotePort, LocalOnly: loc.LocalOnly, Disabled: ln.Disabled, Group: ln.Group, LocalIP: loc.IP, LocalPort: loc.Port, LocalProto: loc.Protocol, } if loc.LocalOnly { fs.Kind = "local" fs.OwnerID = selfID if st, has := h.Process.Status(ln.Remote); has { fs.Active = !ln.Disabled && st.State == "running" } } else { fs.Kind = "remote" owner, inTopo := ownerOf[topoKey{ln.Local, ln.Remote, ln.RemotePort}] fs.OwnerID = owner fs.Active = !ln.Disabled && inTopo } forwards = append(forwards, fs) } resp := map[string]any{ "version": "0.1.0", "workDir": h.WorkDir, "settings": settings, "services": locals, "remotes": remotes, "binaryPath": binary, "profiles": profiles, "localStatus": localStatuses, "forwards": forwards, "selfId": selfID, } writeJSON(w, http.StatusOK, resp) } // handleClusterNodes reports registered cluster nodes and their cached frpc // versions (M6). Used by discovery and the frontend cluster page. func (h *Handler) handleClusterNodes(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w) return } if h.Cluster == nil { http.Error(w, "cluster registry not enabled", http.StatusNotFound) return } // Self node goes first so a querier can identify us by Nodes[0]. The // version we advertise is the newest locally cached frpc (falling back to // the configured binary path), so peers can bootstrap the same version. type nodeResp struct { Addr string `json:"addr"` Cache []string `json:"cache,omitempty"` Version string `json:"version,omitempty"` } out := make([]nodeResp, 0, 1+len(h.Cluster.NodeList())) ver := "" if cs := h.Cluster.CachedVersions(); len(cs) > 0 { ver = cs[len(cs)-1] } else if h.BinaryPath != nil { ver = frpcVersionOf(h.BinaryPath()) } out = append(out, nodeResp{Addr: h.SelfAddr, Cache: h.Cluster.CachedVersions(), Version: ver}) for _, n := range h.Cluster.NodeList() { out = append(out, nodeResp{Addr: n.Addr, Version: n.Version, Cache: n.Cache}) } writeJSON(w, http.StatusOK, map[string]any{"nodes": out}) } // handleFrpcBinary serves a cached frpc binary to peer nodes over // GET /frpc/{version}. Basic Auth is required (same creds as the manager). func (h *Handler) handleFrpcBinary(w http.ResponseWriter, r *http.Request) { if h.Cluster == nil { http.NotFound(w, r) return } name := strings.TrimPrefix(r.URL.Path, "/frpc/") if name == "" || strings.Contains(name, "/") || strings.Contains(name, "..") { http.Error(w, "bad version", http.StatusBadRequest) return } path, checksum, err := h.Cluster.Provide(name) if err != nil { http.NotFound(w, r) return } f, err := os.Open(path) if err != nil { http.NotFound(w, r) return } defer f.Close() w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("X-Frpc-SHA256", checksum) _, _ = io.Copy(w, f) } // handleClusterCache lists cached frpc versions (GET) or prunes (POST {keep:N}). func (h *Handler) handleClusterCache(w http.ResponseWriter, r *http.Request) { if h.Cluster == nil { http.Error(w, "cluster registry not enabled", http.StatusNotFound) return } switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, map[string]any{"cache": h.Cluster.CacheInfo()}) case http.MethodPost: // Route registered at read so GET works; cache pruning needs write. if !hasLevel(r, "write") { forbidden(w) return } var req struct { Keep int `json:"keep"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest) return } if req.Keep < 0 || req.Keep > 50 { http.Error(w, "keep in [0,50]", http.StatusBadRequest) return } removed := h.Cluster.PruneCache(req.Keep) writeJSON(w, http.StatusOK, map[string]any{"removed": removed, "cache": h.Cluster.CacheInfo()}) default: methodNotAllowed(w) } } // handleClusterToken receives the circulating token (POST), lets the ring // engine process it, returns the updated token so the caller can forward it. func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) { if h.Ring == nil { http.Error(w, "ring engine not enabled", http.StatusNotFound) return } if r.Method != http.MethodPost { methodNotAllowed(w) return } // Heartbeat ping: empty body (no token) is a liveness check from the // leader's predecessor (the FALLBACK leader-death path). A node that // restarted (CreateCluster → 1-node standalone) or detached // (detachAsStandalone → 1-node standalone) is NOT the multi-node ring // leader the predecessor thinks it is. Returning 409 makes the // predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader // → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader // 退出节点时,让leader上邻居意识到当前环已经没有节点了". if r.Body == nil { if s := h.Ring.State(); len(s.Nodes) <= 1 { http.Error(w, "standalone node", http.StatusConflict) return } w.WriteHeader(http.StatusOK) return } body, _ := io.ReadAll(r.Body) if len(bytes.TrimSpace(body)) == 0 { if s := h.Ring.State(); len(s.Nodes) <= 1 { http.Error(w, "standalone node", http.StatusConflict) return } w.WriteHeader(http.StatusOK) return } var tk cluster.Token if err := json.Unmarshal(body, &tk); err != nil { http.Error(w, "parse token: "+err.Error(), http.StatusBadRequest) return } updated, err := h.Ring.OnToken(context.Background(), &tk) if err != nil { http.Error(w, "token process: "+err.Error(), http.StatusInternalServerError) return } // OnToken returns nil when it drops a stale/duplicate token: the token is // dead, do NOT hand a nil token onward (would nil-panic in Send/Forward). if updated == nil { w.WriteHeader(http.StatusOK) return } // Onward forwarding is ASYNC: acknowledge receipt immediately (the // token is a relay baton, not a synchronous RPC chain). If we forwarded // synchronously, a slow next hop would make this handler hang for the // upstream client timeout, which would recursively stall the whole ring. nextTK := *updated if h.Ring.IsLeader() { go func() { _ = h.Ring.Send(context.Background(), &nextTK) }() } else { go func() { _ = h.Ring.Forward(context.Background(), &nextTK) }() } writeJSON(w, http.StatusOK, updated) } // handleClusterRing reports the local ring engine state snapshot (frontend). func (h *Handler) handleClusterRing(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w) return } if h.Ring == nil { http.Error(w, "ring engine not enabled", http.StatusNotFound) return } writeJSON(w, http.StatusOK, h.Ring.Snapshot()) } // handleClusterJoin accepts a newcomer join request: the target node inserts // the newcomer after itself in the ring and returns the updated ring state // for the newcomer to adopt. func (h *Handler) handleClusterJoin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w) return } if h.Ring == nil { http.Error(w, "ring engine not enabled", http.StatusNotFound) return } var ji cluster.JoinInfo if err := json.NewDecoder(r.Body).Decode(&ji); err != nil { http.Error(w, "parse join: "+err.Error(), http.StatusBadRequest) return } // Verify the newcomer presents this node's admission key. Without this // check, any client that knows the shared webui password could join and // receive the full cluster picture — the key adds a per-node admission // layer the operator must copy from the sponsor's cluster page. if ji.JoinKey == "" || ji.JoinKey != h.Ring.NodeKey() { http.Error(w, "invalid join key", http.StatusForbidden) return } wasSingle := len(h.Ring.State().Nodes) <= 1 state := h.Ring.JoinNode(ji) writeJSON(w, http.StatusOK, map[string]any{"state": state}) // Kick off the token cycle AFTER the newcomer has adopted (respond first, // then start in background so the new node is no longer single when the // token reaches it). if wasSingle && h.Ring.IsLeader() { go func() { time.Sleep(800 * time.Millisecond) h.Ring.StartRing(context.Background()) }() } } // handleClusterTask accepts a new forward request (intermediate config: // local/remote/link) and submits it to the ring as a pending task. func (h *Handler) handleClusterTask(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w) return } if h.Ring == nil { http.Error(w, "ring engine not enabled", http.StatusNotFound) return } var req struct { Local store.Local `json:"local"` Remote store.Remote `json:"remote"` Link store.Link `json:"link"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "parse task: "+err.Error(), http.StatusBadRequest) return } tk := h.Ring.SubmitTask(req.Local, req.Remote, req.Link) writeJSON(w, http.StatusOK, map[string]any{"task": tk}) } // handleNodeRemove publishes a node-removal command via the token; the // target node self-removes when the command reaches it. func (h *Handler) handleNodeRemove(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w) return } if h.Ring == nil { http.Error(w, "ring engine not enabled", http.StatusNotFound) return } var req struct { ID string `json:"id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "parse: "+err.Error(), http.StatusBadRequest) return } if req.ID == "" { http.Error(w, "node id required", http.StatusBadRequest) return } tk := h.Ring.RemoveNode(req.ID) writeJSON(w, http.StatusOK, map[string]any{"task": tk}) } // handleClusterCreate reseeds this node as a fresh standalone leader (the // runtime "创建集群" path). Refuses with 409 if this node is still a // multi-node member — reseeding mid-cluster would split the ring. func (h *Handler) handleClusterCreate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w) return } if h.Ring == nil { http.Error(w, "ring engine not enabled", http.StatusNotFound) return } if err := h.Ring.CreateCluster(); err != nil { http.Error(w, err.Error(), http.StatusConflict) return } writeJSON(w, http.StatusOK, h.Ring.Snapshot()) } // handleClusterJoinRing is the runtime "加入集群" path: this node joins the // cluster at the given peer address (newcomer-side — it POSTs its own // JoinInfo to the peer's /cluster/join and adopts the returned state). // Synchronous so the UI gets a real success/failure; capped at 10s (the // peer dial itself times out at 8s). Refuses 409 if already a multi-node // member (would split the ring). func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w) return } if h.Ring == nil { http.Error(w, "ring engine not enabled", http.StatusNotFound) return } var req struct { Addr string `json:"addr"` JoinKey string `json:"joinKey"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "parse: "+err.Error(), http.StatusBadRequest) return } if req.Addr == "" { http.Error(w, "addr required", http.StatusBadRequest) return } if req.JoinKey == "" { http.Error(w, "joinKey required", http.StatusBadRequest) return } if h.Ring.IsMember() { http.Error(w, "already a multi-node cluster member; leave first", http.StatusConflict) return } jc, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() if err := h.Ring.JoinRingAddr(jc, req.Addr, req.JoinKey); err != nil { http.Error(w, "join failed: "+err.Error(), http.StatusBadGateway) return } writeJSON(w, http.StatusOK, h.Ring.Snapshot()) } // handleClusterJoin accepts a newcomer join request: the target node inserts // the newcomer after itself in the ring and returns the updated ring state // for the newcomer to adopt. // .../bin/frpc-0.71.0/frpc. Empty when not a versioned cache path. func frpcVersionOf(binPath string) string { dir := filepath.Dir(binPath) base := filepath.Base(dir) if strings.HasPrefix(base, "frpc-") { return strings.TrimPrefix(base, "frpc-") } return "" } // connStateOf derives the frps connection state from the local worker process // state plus a probe of its log. We never control the remote frps: the worker is // our local frpc; "connected" means frpc has successfully logged in to frps. // - disabled -> remote disabled, worker not started // - running+login -> frpc logged in to frps (connected) // - running -> frpc up but not yet logged in (connecting) // - starting/restarting -> connecting // - crashed/exit nonzero -> failed // - stopped clean / no worker -> not_started func connStateOf(workerLog string, enabled bool, st process.Status) string { if !enabled { return "disabled" } switch st.State { case "running": tail, _ := tailFile(workerLog, 64*1024) if tail != "" { // frpc prints "login to server success" once the control link is up. if strings.Contains(tail, "login to server success") || strings.Contains(tail, "start proxy success") { return "connected" } if strings.Contains(tail, "login to server error") || strings.Contains(tail, "connect to server error") || strings.Contains(tail, "connect server error") { return "failed" } } return "connecting" case "starting", "restarting": return "connecting" case "crashed": return "failed" case "stopped": if st.ExitCode != 0 || st.Err != "" { return "failed" } return "not_started" default: return "not_started" } } // fetchProxyStates queries a worker's local frpc admin API (webServer) for // true per-proxy status: GET /api/status returns a map keyed by proxy type, // each value a list of { name, type, status, err, local_addr, remote_addr }. // status is one of frpc's proxy phases: // // new | wait start | start error | running | check failed | closed func fetchProxyStates(addr string, port int, user, pass string) ([]proxyState, error) { if addr == "" { addr = "127.0.0.1" } url := fmt.Sprintf("http://%s:%d/api/status", addr, port) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } req.SetBasicAuth(user, pass) cli := &http.Client{Timeout: 3 * time.Second} resp, err := cli.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, resp.Body) return nil, fmt.Errorf("admin status HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return nil, err } var m map[string][]proxyState if err := json.Unmarshal(body, &m); err != nil { return nil, err } var out []proxyState for _, arr := range m { out = append(out, arr...) } return out, nil } // connStateAdmin derives the overall worker connection state from the per-proxy // states reported by the local frpc admin API. func connStateAdmin(states []proxyState) string { if len(states) == 0 { return "connecting" } anyRunning := false for _, s := range states { switch s.Status { case "running": anyRunning = true case "wait start": return "connecting" } } if anyRunning { return "connected" } return "failed" } // groupCountOf returns the number of distinct non-empty LB groups a remote // participates in, by mapping each forward's service back to its local. func groupCountOf(locals []store.Local, fwd []store.Forward) int { groupByLocal := map[string]string{} for _, l := range locals { if l.LBGroup != "" { groupByLocal[l.Name] = l.LBGroup } } seen := map[string]bool{} for _, f := range fwd { if g := groupByLocal[f.Service]; g != "" { seen[g] = true } } return len(seen) } func methodNotAllowed(w http.ResponseWriter) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = fmtJSONEncode(w, v) } func fmtJSONEncode(w http.ResponseWriter, v any) error { return json.NewEncoder(w).Encode(v) }