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

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