// 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) }