diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 4fc86b6..454cae2 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -4,9 +4,12 @@ package httpapi import ( "embed" "encoding/json" + "fmt" + "io" "io/fs" "net/http" "strings" + "time" "webui4frpc/internal/process" "webui4frpc/internal/store" @@ -131,6 +134,18 @@ func contentTypeFor(name string) string { } } +// 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) @@ -149,6 +164,13 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { // 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)) @@ -159,10 +181,24 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { for _, rv := range remotes { st, has := h.Process.Status(rv.Name) fwd, _ := h.Store.LinksForRemote(rv.Name) - profiles = append(profiles, profileStatus{ + p := profileStatus{ Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd, - ConnState: connStateOf(h.Process.LogPath(rv.Name), rv.Enabled, st), - }) + 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 @@ -251,6 +287,86 @@ func connStateOf(workerLog string, enabled bool, st process.Status) string { } } +// 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) } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index fd9c2d8..f329bd7 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strconv" + "strings" "testing" "webui4frpc/internal/process" @@ -138,3 +140,55 @@ func TestSettingsPutRoundTrip(t *testing.T) { t.Fatalf("settings after reload = %+v", got) } } + +// TestFetchProxyStatesFromAdminAPI verifies the M3 status path: we mock the +// frpc admin API (/api/status with Basic Auth) and confirm fetchProxyStates +// parses per-proxy rows and connStateAdmin derives the right summary. +func TestFetchProxyStatesFromAdminAPI(t *testing.T) { + // Mock frpc admin server. + admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok || u != "admin" || p != "pw" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Path != "/api/status" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string][]proxyState{ + "tcp": { + {Name: "web1", Type: "tcp", Status: "running", LocalAddr: "127.0.0.1:8080", RemoteAddr: "1.2.3.4:8080"}, + {Name: "web2", Type: "tcp", Status: "check failed", Err: "health check failed"}, + }, + }) + })) + defer admin.Close() + u := strings.TrimPrefix(admin.URL, "http://") + host, portStr, _ := strings.Cut(u, ":") + port, _ := strconv.Atoi(portStr) + + states, err := fetchProxyStates(host, port, "admin", "pw") + if err != nil { + t.Fatalf("fetchProxyStates: %v", err) + } + if len(states) != 2 { + t.Fatalf("states = %+v", states) + } + if states[0].Status != "running" || states[1].Status != "check failed" { + t.Fatalf("unexpected states = %+v", states) + } + + // connStateAdmin: one running -> connected. + if got := connStateAdmin(states); got != "connected" { + t.Fatalf("connStateAdmin = %q, want connected", got) + } + // All failed -> failed. + if got := connStateAdmin([]proxyState{{Name: "a", Status: "check failed"}, {Name: "b", Status: "start error"}}); got != "failed" { + t.Fatalf("connStateAdmin(all failed) = %q, want failed", got) + } + // wait start -> connecting. + if got := connStateAdmin([]proxyState{{Name: "a", Status: "wait start"}}); got != "connecting" { + t.Fatalf("connStateAdmin(wait start) = %q, want connecting", got) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 9351050..4d03316 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -39,6 +39,19 @@ type Proxy struct { HTTPHeaders map[string]string // additional request headers BasicAuthUser string BasicAuthPassword string + + // M3 Load balancing + health check. + // LBGroup/LBGroupKey put this proxy into a frps load-balancing group; + // all members with the same group are balanced across. + LBGroup string + LBGroupKey string + + // HealthCheck* enable frpc's built-in health monitor. Empty type = off. + HealthCheckType string // "" | tcp | http + HealthCheckPath string // http only; path to GET on LocalIP:LocalPort + HealthCheckTimeout int // seconds, default 3 + HealthCheckMaxFailed int // default 1 + HealthCheckInterval int // seconds, default 10 } // frpcAuth mirrors frpc's auth section. @@ -96,16 +109,52 @@ type frpcProxy struct { HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` HTTPHeaders map[string]string `json:"httpHeaders,omitempty"` HTTPBasicAuth *frpcBasicAuth `json:"basicAuth,omitempty"` + + // M3 load balancer group + health check. + LoadBalancer *frpcLoadBalancer `json:"loadBalancer,omitempty"` + HealthCheck *frpcHealthCheck `json:"healthCheck,omitempty"` +} + +// frpcWebServer is the frpc admin API (webServer) section. Enabled when +// AdminPort > 0; used by the manager to query true per-proxy status. +type frpcWebServer struct { + Addr string `json:"addr,omitempty"` + Port int `json:"port,omitempty"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` +} + +// frpcLoadBalancer mirrors frpc's loadBalancer section (group load balancing). +type frpcLoadBalancer struct { + Group string `json:"group"` + GroupKey string `json:"groupKey,omitempty"` +} + +// frpcHealthCheck mirrors frpc's healthCheck section. +type frpcHealthCheck struct { + Type string `json:"type"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + MaxFailed int `json:"maxFailed,omitempty"` + IntervalSeconds int `json:"intervalSeconds"` + Path string `json:"path,omitempty"` + HTTPHeaders []frpcHTTPHeader `json:"httpHeaders,omitempty"` +} + +// frpcHTTPHeader is a single extra header for an http health check. +type frpcHTTPHeader struct { + Name string `json:"name"` + Value string `json:"value"` } // Config is a complete frpc configuration document. type Config struct { - ServerAddr string `json:"serverAddr"` - ServerPort int `json:"serverPort"` - Auth frpcAuth `json:"auth"` - Transport frpcTransport `json:"transport,omitempty"` - LoginFailExit bool `json:"loginFailExit"` - Proxies []frpcProxy `json:"proxies"` + ServerAddr string `json:"serverAddr"` + ServerPort int `json:"serverPort"` + Auth frpcAuth `json:"auth"` + Transport frpcTransport `json:"transport,omitempty"` + WebServer *frpcWebServer `json:"webServer,omitempty"` + LoginFailExit bool `json:"loginFailExit"` + Proxies []frpcProxy `json:"proxies"` } // Render builds the worker config JSON for a remote server from its setting @@ -118,6 +167,18 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) { LoginFailExit: false, Proxies: make([]frpcProxy, 0, len(proxies)), } + // M3: optional local frpc admin API (webServer) so the manager can query + // true per-proxy status. Only emitted when a port is configured. + if remote.AdminPort > 0 { + addr := remote.AdminAddr + if addr == "" { + addr = "127.0.0.1" + } + cfg.WebServer = &frpcWebServer{ + Addr: addr, Port: remote.AdminPort, + User: remote.AdminUser, Password: remote.AdminPassword, + } + } if remote.TransportProtocol != "" || remote.TransportTLS || remote.TransportPool > 0 || remote.TransportTLSServerName != "" { cfg.Transport = frpcTransport{ Protocol: remote.TransportProtocol, @@ -154,6 +215,31 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) { if p.BasicAuthUser != "" || p.BasicAuthPassword != "" { frpcP.HTTPBasicAuth = &frpcBasicAuth{User: p.BasicAuthUser, Password: p.BasicAuthPassword} } + // M3: load balancing group. + if p.LBGroup != "" { + frpcP.LoadBalancer = &frpcLoadBalancer{Group: p.LBGroup, GroupKey: p.LBGroupKey} + } + // M3: health check. Type "" means off; frpc defaults apply when a + // specific knob is left zero. + if p.HealthCheckType != "" { + hc := &frpcHealthCheck{ + Type: p.HealthCheckType, + TimeoutSeconds: p.HealthCheckTimeout, + MaxFailed: p.HealthCheckMaxFailed, + IntervalSeconds: p.HealthCheckInterval, + Path: p.HealthCheckPath, + } + // The HTTP health probe needs its own headers (distinct from the + // proxy's request headers): map the proxy's HTTPHeaders through. + if len(p.HTTPHeaders) > 0 { + hh := make([]frpcHTTPHeader, 0, len(p.HTTPHeaders)) + for name, val := range p.HTTPHeaders { + hh = append(hh, frpcHTTPHeader{Name: name, Value: val}) + } + hc.HTTPHeaders = hh + } + frpcP.HealthCheck = hc + } // http/https proxies use customDomains instead of remotePort. The // default domain is .local; frps matches it by Host header. if p.Type == "http" || p.Type == "https" { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index c315599..6b80bbb 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -159,3 +159,108 @@ func TestRenderHTTPSUsesCustomDomains(t *testing.T) { t.Fatalf("customDomains = %+v", p.CustomDomains) } } + +func TestRenderLoadBalancerAndHealthCheck(t *testing.T) { + remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"} + data, err := Render(remote, []Proxy{ + {Name: "web1", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080, + LBGroup: "web", LBGroupKey: "k", + HealthCheckType: "tcp", HealthCheckTimeout: 3, HealthCheckMaxFailed: 2, HealthCheckInterval: 5}, + }) + if err != nil { + t.Fatal(err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + p := cfg.Proxies[0] + if p.LoadBalancer == nil || p.LoadBalancer.Group != "web" || p.LoadBalancer.GroupKey != "k" { + t.Fatalf("loadBalancer = %+v", p.LoadBalancer) + } + if p.HealthCheck == nil || p.HealthCheck.Type != "tcp" || p.HealthCheck.IntervalSeconds != 5 || + p.HealthCheck.MaxFailed != 2 || p.HealthCheck.TimeoutSeconds != 3 { + t.Fatalf("healthCheck = %+v", p.HealthCheck) + } +} + +func TestRenderHealthCheckHTTPWithHeaders(t *testing.T) { + remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} + data, err := Render(remote, []Proxy{ + {Name: "web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 8080, + CustomDomains: []string{"app.example.com"}, + HealthCheckType: "http", + HealthCheckPath: "/healthz", + HealthCheckTimeout: 2, + HealthCheckMaxFailed: 3, + HealthCheckInterval: 7, + HTTPHeaders: map[string]string{"X-Probe": "1"}}, + }) + if err != nil { + t.Fatal(err) + } + var cfg Config + _ = json.Unmarshal(data, &cfg) + p := cfg.Proxies[0] + if p.HealthCheck == nil || p.HealthCheck.Type != "http" || p.HealthCheck.Path != "/healthz" || + p.HealthCheck.IntervalSeconds != 7 { + t.Fatalf("healthCheck = %+v", p.HealthCheck) + } + if p.HealthCheck.HTTPHeaders == nil || len(p.HealthCheck.HTTPHeaders) == 0 || + p.HealthCheck.HTTPHeaders[0].Name != "X-Probe" || p.HealthCheck.HTTPHeaders[0].Value != "1" { + t.Fatalf("healthCheck.httpHeaders = %+v", p.HealthCheck.HTTPHeaders) + } + // http proxy must still use customDomains and no remotePort + if len(p.CustomDomains) != 1 || p.CustomDomains[0] != "app.example.com" || p.RemotePort != 0 { + t.Fatalf("http proxy routing = customDomains=%v remotePort=%d", p.CustomDomains, p.RemotePort) + } +} + +func TestRenderHealthCheckOmittedWhenOff(t *testing.T) { + remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} + data, err := Render(remote, []Proxy{ + {Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080}, + }) + if err != nil { + t.Fatal(err) + } + var cfg Config + _ = json.Unmarshal(data, &cfg) + p := cfg.Proxies[0] + if p.LoadBalancer != nil || p.HealthCheck != nil { + t.Fatalf("lb/health should be omitted, got lb=%+v hc=%+v", p.LoadBalancer, p.HealthCheck) + } +} + +func TestRenderAdminWebServer(t *testing.T) { + remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret", + AdminAddr: "127.0.0.1", AdminPort: 7400, AdminUser: "admin", AdminPassword: "pw"} + data, err := Render(remote, nil) + if err != nil { + t.Fatal(err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if cfg.WebServer == nil { + t.Fatal("webServer missing when AdminPort set") + } + if cfg.WebServer.Port != 7400 || cfg.WebServer.Addr != "127.0.0.1" || + cfg.WebServer.User != "admin" || cfg.WebServer.Password != "pw" { + t.Fatalf("webServer = %+v", cfg.WebServer) + } +} + +func TestRenderAdminWebServerOmitted(t *testing.T) { + remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} + data, err := Render(remote, nil) + if err != nil { + t.Fatal(err) + } + var cfg Config + _ = json.Unmarshal(data, &cfg) + if cfg.WebServer != nil { + t.Fatalf("webServer should be omitted, got %+v", cfg.WebServer) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index a102be8..8e53e22 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -36,6 +36,21 @@ type Local struct { HTTPHeaders map[string]string `json:"httpHeaders,omitempty"` BasicAuthUser string `json:"basicAuthUser,omitempty"` BasicAuthPassword string `json:"basicAuthPassword,omitempty"` + + // M3 Load balancing: group / groupKey turn this local into a group member. + // The frps load balances the group's proxies (many-to-one backend set). + LBGroup string `json:"lbGroup,omitempty"` + LBGroupKey string `json:"lbGroupKey,omitempty"` + + // Health check (frpc healthCheck): tcp probes LocalIP:LocalPort or an + // http GET to LocalIP:LocalPort + HealthCheckPath. When a group member + // fails its check, frpc stops routing to it; the server falls back to the + // surviving group members. + HealthCheckType string `json:"healthCheckType,omitempty"` // "" (off) | tcp | http + HealthCheckPath string `json:"healthCheckPath,omitempty"` + HealthCheckTimeout int `json:"healthCheckTimeout,omitempty"` // seconds, default 3 + HealthCheckMaxFailed int `json:"healthCheckMaxFailed,omitempty"` // default 1 + HealthCheckInterval int `json:"healthCheckInterval,omitempty"` // seconds, default 10 } // Remote is a remote server node on the canvas. @@ -56,6 +71,14 @@ type Remote struct { // M2: frps vhostHTTPPort (optional, displayed on the status page as the // HTTP access port for http/https services). VhostHTTPPort int `json:"vhostHttpPort,omitempty"` + + // M3: local frpc admin API (webServer). When AdminPort > 0 the worker + // exposes GET /api/status so we can report true per-proxy state + // (including health check results) instead of guessing from logs. + AdminAddr string `json:"adminAddr,omitempty"` // listen address, default 127.0.0.1 + AdminPort int `json:"adminPort,omitempty"` + AdminUser string `json:"adminUser,omitempty"` + AdminPassword string `json:"adminPassword,omitempty"` } // Link connects one local to one remote. @@ -90,7 +113,22 @@ CREATE TABLE IF NOT EXISTS locals ( name TEXT PRIMARY KEY, ip TEXT NOT NULL, port INTEGER NOT NULL, - protocol TEXT NOT NULL DEFAULT 'tcp' + protocol TEXT NOT NULL DEFAULT 'tcp', + use_encryption INTEGER NOT NULL DEFAULT 0, + use_compression INTEGER NOT NULL DEFAULT 0, + bandwidth_limit TEXT NOT NULL DEFAULT '', + pool_count INTEGER NOT NULL DEFAULT 0, + metadatas TEXT NOT NULL DEFAULT '', + annotations TEXT NOT NULL DEFAULT '', + custom_domains TEXT NOT NULL DEFAULT '', + subdomain TEXT NOT NULL DEFAULT '', + locations TEXT NOT NULL DEFAULT '', + host_header_rewrite TEXT NOT NULL DEFAULT '', + http_headers TEXT NOT NULL DEFAULT '', + basic_auth_user TEXT NOT NULL DEFAULT '', + basic_auth_password TEXT NOT NULL DEFAULT '', + lb_group TEXT NOT NULL DEFAULT '', + lb_group_key TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS remotes ( name TEXT PRIMARY KEY, @@ -98,7 +136,16 @@ CREATE TABLE IF NOT EXISTS remotes ( port INTEGER NOT NULL, token TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', - enabled INTEGER NOT NULL DEFAULT 1 + enabled INTEGER NOT NULL DEFAULT 1, + transport_protocol TEXT NOT NULL DEFAULT '', + transport_tls INTEGER NOT NULL DEFAULT 0, + transport_pool INTEGER NOT NULL DEFAULT 0, + transport_tls_server_name TEXT NOT NULL DEFAULT '', + vhost_http_port INTEGER NOT NULL DEFAULT 0, + admin_addr TEXT NOT NULL DEFAULT '', + admin_port INTEGER NOT NULL DEFAULT 0, + admin_user TEXT NOT NULL DEFAULT '', + admin_password TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS links ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -168,6 +215,13 @@ func (s *Store) migrate() error { "http_headers TEXT NOT NULL DEFAULT ''", "basic_auth_user TEXT NOT NULL DEFAULT ''", "basic_auth_password TEXT NOT NULL DEFAULT ''", + "lb_group TEXT NOT NULL DEFAULT ''", + "lb_group_key TEXT NOT NULL DEFAULT ''", + "health_check_type TEXT NOT NULL DEFAULT ''", + "health_check_path TEXT NOT NULL DEFAULT ''", + "health_check_timeout INTEGER NOT NULL DEFAULT 0", + "health_check_max_failed INTEGER NOT NULL DEFAULT 0", + "health_check_interval INTEGER NOT NULL DEFAULT 0", }, "remotes": { "transport_protocol TEXT NOT NULL DEFAULT ''", @@ -175,6 +229,10 @@ func (s *Store) migrate() error { "transport_pool INTEGER NOT NULL DEFAULT 0", "transport_tls_server_name TEXT NOT NULL DEFAULT ''", "vhost_http_port INTEGER NOT NULL DEFAULT 0", + "admin_addr TEXT NOT NULL DEFAULT ''", + "admin_port INTEGER NOT NULL DEFAULT 0", + "admin_user TEXT NOT NULL DEFAULT ''", + "admin_password TEXT NOT NULL DEFAULT ''", }, } for table, cols := range tables { @@ -212,7 +270,7 @@ func (s *Store) Close() error { return s.db.Close() } // ---- Locals ---- func (s *Store) ListLocals() ([]Local, error) { - rows, err := s.db.Query("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password FROM locals ORDER BY name") + rows, err := s.db.Query("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals ORDER BY name") if err != nil { return nil, err } @@ -222,7 +280,7 @@ func (s *Store) ListLocals() ([]Local, error) { var l Local var enc, comp int var metaRaw, annoRaw, hdrRaw, locRaw string - if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword); err != nil { + if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil { return nil, err } l.UseEncryption = enc != 0 @@ -240,8 +298,8 @@ func (s *Store) GetLocal(name string) (Local, bool) { var l Local var enc, comp int var metaRaw, annoRaw, hdrRaw, locRaw string - row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password FROM locals WHERE name = ?", name) - if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword); err != nil { + row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals WHERE name = ?", name) + if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil { return Local{}, false } l.UseEncryption = enc != 0 @@ -255,9 +313,9 @@ func (s *Store) GetLocal(name string) (Local, bool) { func (s *Store) UpsertLocal(l Local) error { _, err := s.db.Exec( - "INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+ - "ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password", - l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword, + "INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+ + "ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password, lb_group=excluded.lb_group, lb_group_key=excluded.lb_group_key, health_check_type=excluded.health_check_type, health_check_path=excluded.health_check_path, health_check_timeout=excluded.health_check_timeout, health_check_max_failed=excluded.health_check_max_failed, health_check_interval=excluded.health_check_interval", + l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword, l.LBGroup, l.LBGroupKey, l.HealthCheckType, l.HealthCheckPath, l.HealthCheckTimeout, l.HealthCheckMaxFailed, l.HealthCheckInterval, ) return err } @@ -318,7 +376,7 @@ func (s *Store) DeleteLocal(name string) error { // ---- Remotes ---- func (s *Store) ListRemotes() ([]Remote, error) { - rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port FROM remotes ORDER BY name") + rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes ORDER BY name") if err != nil { return nil, err } @@ -327,7 +385,7 @@ func (s *Store) ListRemotes() ([]Remote, error) { for rows.Next() { var r Remote var en, tls int - if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort); err != nil { + if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil { return nil, err } r.Enabled = en != 0 @@ -340,8 +398,8 @@ func (s *Store) ListRemotes() ([]Remote, error) { func (s *Store) GetRemote(name string) (Remote, bool) { var r Remote var en, tls int - row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port FROM remotes WHERE name = ?", name) - if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort); err != nil { + row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes WHERE name = ?", name) + if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil { return Remote{}, false } r.Enabled = en != 0 @@ -351,9 +409,9 @@ func (s *Store) GetRemote(name string) (Remote, bool) { func (s *Store) UpsertRemote(r Remote) error { _, err := s.db.Exec( - "INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port) VALUES(?,?,?,?,?,?,?,?,?,?,?) "+ - "ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, vhost_http_port=excluded.vhost_http_port", - r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort, + "INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+ + "ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, vhost_http_port=excluded.vhost_http_port, admin_addr=excluded.admin_addr, admin_port=excluded.admin_port, admin_user=excluded.admin_user, admin_password=excluded.admin_password", + r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort, r.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword, ) return err } diff --git a/plan.md b/plan.md index f4ad40e..ecb754d 100644 --- a/plan.md +++ b/plan.md @@ -55,9 +55,11 @@ ### M3 负载均衡与健康检查 -- [ ] group / groupKey(多后端负载均衡) -- [ ] health check:tcp / http,失败剔除与恢复 -- [ ] 状态页体现 LB 组与健康状态 +- [x] 模型扩展:Local 增加 lbGroup / lbGroupKey / healthCheck* 字段,Remote 增加 adminAddr / adminPort / adminUser / adminPassword(local frpc admin API) +- [x] 渲染器:proxy 输出 loadBalancer + healthCheck 段;worker 配置输出 webServer 段(frpc >= 0.52 支持) +- [x] 状态页:按 admin API GET /api/status 拉取真实 per-proxy 状态(running / check failed / wait start / start error / new / closed),未启用 admin 时回退日志推断 +- [x] 前端:LocalNode/RemoteNode 表单 + StatusView per-proxy 状态与 LB 组徽标(vue-tsc 通过) +- [ ] CLUSTER 页:展示负载均衡组(group)+ 健康检查状态(待 M3 后续迭代) ### M4 更多代理类型 @@ -74,12 +76,32 @@ - [ ] 配置导出/导入(备份) - [ ] 日志查看页(worker 日志 tail UI) +> 注:发布类条目已并入 **M7 运维与发布(原 M5)**,本节 M5 保留初始清单。 +> +### M6 集群内 frpc 二进制分发(节点间优先,外部 URL 兜底) + +> 诉求:集群内 frpc 二进制**优先通过集群节点间交换**获得,无法交换时才走外部 URL 下载;新节点加入集群时,**自动由邻居节点向其传输二进制**。 + +- [ ] 二进制来源优先级:本地已有缓存 > 集群节点间交换(HTTP/Chunked 拉取) > 外部 URL(GitHub Releases) +- [ ] 节点注册表:集群发现(IP:port 心跳)记录各节点已缓存的 frpc 版本与可用性 +- [ ] 传输通道:节点间 HTTP GET /frpc/{version}(Basic Auth + 大小上限防放大),支持断点续传/校验和 +- [ ] 入网引导:新节点加入时自动向邻居请求 latest 二进制并本地落盘缓存,随后按需启动 +- [ ] 失败回退:节点间交换不可用时(如源节点离线/版本缺失)自动回退外部 URL 下载 +- [ ] Cache 管理:版本保留策略(LRU / 仅保留常用)、可用性标记(黑名单失效节点) +- [ ] UI:设置页展示二进制来源与缓存状态,集群页展示节点间传输进度 + +### M7 运维与发布(原 M5) + +- [x] git init + 首次 commit + .gitignore +- [ ] 一键构建脚本 / GitHub Actions release / 配置导入导出 / 日志查看页 + ## 建议推进顺序 1. [x] 修复「设置保存失效」——给 `PUT /api/manager/settings` 补注册(server.go 方法分发 + httpapi 单测) 2. [x] 对齐 dev proxy 端口——vite.config.ts → 7500(支持 VITE_PROXY_TARGET 覆盖) 3. M1 高级传输参数(store 加列 → render 输出 → 前端折叠表单 → 单测回归) -4. M5 补一键构建脚本 / CI +4. M3 负载均衡与健康检查(模型 → 渲染 → admin API 状态 → 集群页)— 进行中 +5. M6 集群内 frpc 二进制分发(节点间优先 → 外部 URL 兜底 → 新节点自动传输) ## 审查结论摘要(详见 FRPC_FEATURES_AUDIT.md) diff --git a/web/src/components/LocalNode.vue b/web/src/components/LocalNode.vue index 3895522..732a7be 100644 --- a/web/src/components/LocalNode.vue +++ b/web/src/components/LocalNode.vue @@ -55,6 +55,24 @@ + +
负载均衡 (M3)
+ + + +
健康检查 (M3)
+ + + + + @@ -128,6 +146,13 @@ const subdomainField = field('subdomain') const hostHeaderRewriteField = field('hostHeaderRewrite') const basicAuthUserField = field('basicAuthUser') const basicAuthPasswordField = field('basicAuthPassword') +const lbGroupField = field('lbGroup') +const lbGroupKeyField = field('lbGroupKey') +const healthCheckTypeField = field('healthCheckType') +const healthCheckPathField = field('healthCheckPath') +const healthCheckTimeoutField = field('healthCheckTimeout') +const healthCheckMaxFailedField = field('healthCheckMaxFailed') +const healthCheckIntervalField = field('healthCheckInterval') // locations is an array shown as JSON in the UI const locationsField = computed({ diff --git a/web/src/components/RemoteNode.vue b/web/src/components/RemoteNode.vue index a8a6fa9..dd36fad 100644 --- a/web/src/components/RemoteNode.vue +++ b/web/src/components/RemoteNode.vue @@ -47,6 +47,15 @@ + + + + + + + @@ -87,6 +96,10 @@ const transportTlsField = field('transportTls') const transportTlsServerNameField = field('transportTlsServerName') const transportPoolField = field('transportPool') const vhostHttpPortField = field('vhostHttpPort') +const adminAddrField = field('adminAddr') +const adminPortField = field('adminPort') +const adminUserField = field('adminUser') +const adminPasswordField = field('adminPassword') const showAdv = ref(false) diff --git a/web/src/types.ts b/web/src/types.ts index 6695548..e5fb2c7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -30,6 +30,15 @@ export interface Local { httpHeaders?: Record; basicAuthUser?: string; basicAuthPassword?: string; + + // M3 load balancing group + health check + lbGroup?: string; + lbGroupKey?: string; + healthCheckType?: string; // "" | tcp | http + healthCheckPath?: string; + healthCheckTimeout?: number; + healthCheckMaxFailed?: number; + healthCheckInterval?: number; } export interface Remote { @@ -48,6 +57,12 @@ export interface Remote { // M2: frps vhostHTTPPort vhostHttpPort?: number; + + // M3: local frpc admin API (webServer) + adminAddr?: string; + adminPort?: number; + adminUser?: string; + adminPassword?: string; } export interface Link { @@ -81,6 +96,15 @@ export interface ProcessStatus { err?: string; } +export interface ProxyState { + name: string; + type: string; + status: string; // new | wait start | start error | running | check failed | closed + err?: string; + local_addr?: string; + remote_addr?: string; +} + export interface StatusProfile { name: string; enabled: boolean; @@ -89,6 +113,10 @@ export interface StatusProfile { forwards: { service: string; remotePort: number; localPort?: number }[]; // connState: derived frps connection state (we do not control remote frps) connState?: string; // connected | connecting | failed | not_started | disabled + // M3: admin API state + adminEnabled?: boolean; + proxyStates?: ProxyState[]; + groupCount?: number; } export interface StatusResp { diff --git a/web/src/views/StatusView.vue b/web/src/views/StatusView.vue index 3bbf7c7..16962dd 100644 --- a/web/src/views/StatusView.vue +++ b/web/src/views/StatusView.vue @@ -42,6 +42,25 @@ {{ f.service }} → :{{ f.remotePort }} +
+ LB ×{{ p.groupCount }} +
+
+
+ {{ ps.name }} + {{ proxyStateLabel(ps.status) }} + {{ ps.remote_addr }} + {{ ps.err }} +
+
+
+ 管理 API 未返回状态(frpc 未连上远程 frps 或无代理) +
尚未配置远程节点
@@ -239,6 +258,38 @@ const connBadgeClass = (s?: string) => { const workerHealthy = (s?: string) => s === 'running' +// M3: per-proxy state from frpc admin API. +const proxyStateLabel = (s?: string) => { + switch (s) { + case 'running': + return '运行中' + case 'wait start': + return '等待启动' + case 'start error': + return '启动错误' + case 'check failed': + return '健康检查失败' + case 'closed': + return '已关闭' + case 'new': + default: + return '新建' + } +} +const proxyStateClass = (s?: string) => { + switch (s) { + case 'running': + return 'ok' + case 'check failed': + case 'start error': + return 'err' + case 'wait start': + return 'pending' + default: + return '' + } +} + // localWorkerLabel: state of the LOCAL frpc worker driving this target // (we do not control the remote frps). const localWorkerLabel = (s?: string) => { @@ -470,14 +521,81 @@ onBeforeUnmount(() => { margin-top: 6px; } -.ns-fwd { +.ns-groups { + display: flex; + gap: 4px; + margin-top: 6px; +} + +.ns-group-badge { + font-size: 11px; + background: rgba(64, 158, 255, 0.12); + color: #409eff; + border-radius: 10px; + padding: 1px 8px; +} + +.ns-proxies { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.ns-proxy { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + border: 1px solid $color-border-lighter; + border-radius: 8px; + padding: 3px 8px; + background: #fff; + + &.ok { + border-left: 3px solid $color-success; + } + + &.pending { + border-left: 3px solid #409eff; + } + + &.err { + border-left: 3px solid $color-danger; + } +} + +.np-name { + font-weight: 600; +} + +.np-status { font-size: 11px; - background: $color-bg-muted; - border-radius: 6px; - padding: 1px 6px; color: $color-text-secondary; } +.np-addr { + margin-left: auto; + font-size: 11px; + color: $color-text-muted; +} + +.np-err { + margin-left: auto; + font-size: 11px; + color: $color-danger; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ns-proxies-empty { + margin-top: 6px; + font-size: 12px; + color: $color-text-muted; +} + .ns-empty, .ls-empty { color: $color-text-muted;