feat: M3 load balancing + health check (model/render/admin API status/UI)

This commit is contained in:
2026-08-17 17:45:03 +08:00
parent bd3a62a017
commit 467c7124ab
10 changed files with 658 additions and 33 deletions

View File

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

View File

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

View File

@ -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 <name>.local; frps matches it by Host header.
if p.Type == "http" || p.Type == "https" {

View File

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

View File

@ -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
}

30
plan.md
View File

@ -55,9 +55,11 @@
### M3 负载均衡与健康检查
- [ ] group / groupKey(多后端负载均衡
- [ ] health checktcp / http失败剔除与恢复
- [ ] 状态页体现 LB 组与健康状态
- [x] 模型扩展Local 增加 lbGroup / lbGroupKey / healthCheck* 字段Remote 增加 adminAddr / adminPort / adminUser / adminPasswordlocal frpc admin API
- [x] 渲染器proxy 输出 loadBalancer + healthCheckworker 配置输出 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 拉取) > 外部 URLGitHub 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

View File

@ -55,6 +55,24 @@
<label>请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
<label>BasicAuth 用户 <input v-model="basicAuthUserField" placeholder="user" /></label>
<label>BasicAuth 密码 <input v-model="basicAuthPasswordField" type="password" placeholder="pass" /></label>
<div class="adv-sep">负载均衡 (M3)</div>
<label>LB <input v-model="lbGroupField" placeholder="如 web (同组自动负载均衡)" /></label>
<label>组密钥 <input v-model="lbGroupKeyField" placeholder="组内相同" /></label>
<div class="adv-sep">健康检查 (M3)</div>
<label
>类型
<select v-model="healthCheckTypeField">
<option value="">关闭</option>
<option value="tcp">tcp</option>
<option value="http">http</option>
</select>
</label>
<label>HTTP 路径 <input v-model="healthCheckPathField" placeholder="/healthz" /></label>
<label>超时() <input v-model.number="healthCheckTimeoutField" type="number" min="0" placeholder="默认3" /></label>
<label>失败次数 <input v-model.number="healthCheckMaxFailedField" type="number" min="0" placeholder="默认1" /></label>
<label>间隔() <input v-model.number="healthCheckIntervalField" type="number" min="0" placeholder="默认10" /></label>
</div>
</div>
</div>
@ -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({

View File

@ -47,6 +47,15 @@
<label>连接池 <input v-model.number="transportPoolField" type="number" min="0" /></label>
<label class="adv-sep">HTTP 访问端口 (frps vhostHTTPPort)</label>
<label>vhostHTTPPort <input v-model.number="vhostHttpPortField" type="number" min="0" /></label>
<label class="adv-sep">本地 frpc 管理 API (M3)</label>
<label class="adv-check">启用后状态页按真实代理状态展示
<span class="hint-txt">(adminPort &gt; 0)</span>
</label>
<label>地址 <input v-model="adminAddrField" placeholder="127.0.0.1" /></label>
<label>端口 <input v-model.number="adminPortField" type="number" min="0" placeholder="如 7400" /></label>
<label>用户 <input v-model="adminUserField" placeholder="admin" /></label>
<label>密码 <input v-model="adminPasswordField" type="password" placeholder="可选" /></label>
</div>
</div>
</div>
@ -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)
</script>

View File

@ -30,6 +30,15 @@ export interface Local {
httpHeaders?: Record<string, string>;
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 {

View File

@ -42,6 +42,25 @@
{{ f.service }} :{{ f.remotePort }}
</span>
</div>
<div v-if="p.groupCount" class="ns-groups">
<span class="ns-group-badge">LB ×{{ p.groupCount }}</span>
</div>
<div v-if="p.proxyStates?.length" class="ns-proxies">
<div
v-for="ps in p.proxyStates"
:key="ps.name"
class="ns-proxy"
:class="proxyStateClass(ps.status)"
>
<span class="np-name">{{ ps.name }}</span>
<span class="np-status">{{ proxyStateLabel(ps.status) }}</span>
<span v-if="ps.remote_addr" class="np-addr">{{ ps.remote_addr }}</span>
<span v-if="ps.err" class="np-err" :title="ps.err">{{ ps.err }}</span>
</div>
</div>
<div v-else-if="p.adminEnabled" class="ns-proxies-empty">
管理 API 未返回状态frpc 未连上远程 frps 或无代理
</div>
</div>
<div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
</div>
@ -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;