feat: M2 HTTP/HTTPS 完善

- store: Local 增加 customDomains/subdomain/locations/hostHeaderRewrite/httpHeaders/basicAuth;Remote 增加 vhostHttpPort;migrate() 补列;CRUD 读写(JSOm 编解码)

- render: http/https proxy 输出 customDomains/subdomain/locations/hostHeaderRewrite/httpHeaders/basicAuth

- main: renderRemote 透传 M2 字段(customDomains 逗号拆分 splitCSV)

- web: LocalNode 折叠 HTTP 路由高级区;RemoteNode vhostHTTPPort;StatusView 显示 HTTP 访问端口

- 测试: TestRenderHTTPAdvanced / TestRenderHTTPSUsesCustomDomains;端到端 PUT canvas→config 验证通过
This commit is contained in:
2026-08-17 12:59:12 +08:00
parent 07f25ea067
commit 58906c5a81
11 changed files with 323 additions and 116 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
<script type="module" crossorigin src="/assets/index-D5XN_p_5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B38pSFA7.css">
<script type="module" crossorigin src="/assets/index-DHaXL5Wt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-m13EHiyb.css">
</head>
<body>
<div id="app"></div>

View File

@ -32,6 +32,13 @@ type Proxy struct {
PoolCount int
Metadatas map[string]string
Annotations map[string]string
// M2 HTTP/HTTPS routing (http/https only).
Locations []string // path routing
HostHeaderRewrite string // rewrite Host header
HTTPHeaders map[string]string // additional request headers
BasicAuthUser string
BasicAuthPassword string
}
// frpcAuth mirrors frpc's auth section.
@ -53,6 +60,12 @@ type frpcTLS struct {
ServerName string `json:"serverName,omitempty"`
}
// frpcBasicAuth mirrors frpc's HTTP basicAuth section.
type frpcBasicAuth struct {
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
}
// frpcProxy is the serialized proxy object. Different types use different
// fields; empty ones are omitted so the JSON is accepted by frpc.
type frpcProxy struct {
@ -71,6 +84,12 @@ type frpcProxy struct {
PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
// M2 HTTP/HTTPS routing.
Locations []string `json:"locations,omitempty"`
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
HTTPBasicAuth *frpcBasicAuth `json:"basicAuth,omitempty"`
}
// Config is a complete frpc configuration document.
@ -109,17 +128,23 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
nameCounts := make(map[string]int)
for _, p := range proxies {
frpcP := frpcProxy{
Name: p.Name,
Type: p.Type,
LocalIP: p.LocalIP,
LocalPort: p.LocalPort,
SubDomain: p.SubDomain,
UseEncryption: p.UseEncryption,
UseCompression: p.UseCompression,
BandwidthLimit: p.BandwidthLimit,
PoolCount: p.PoolCount,
Metadatas: p.Metadatas,
Annotations: p.Annotations,
Name: p.Name,
Type: p.Type,
LocalIP: p.LocalIP,
LocalPort: p.LocalPort,
SubDomain: p.SubDomain,
UseEncryption: p.UseEncryption,
UseCompression: p.UseCompression,
BandwidthLimit: p.BandwidthLimit,
PoolCount: p.PoolCount,
Metadatas: p.Metadatas,
Annotations: p.Annotations,
Locations: p.Locations,
HostHeaderRewrite: p.HostHeaderRewrite,
HTTPHeaders: p.HTTPHeaders,
}
if p.BasicAuthUser != "" || p.BasicAuthPassword != "" {
frpcP.HTTPBasicAuth = &frpcBasicAuth{User: p.BasicAuthUser, Password: p.BasicAuthPassword}
}
// http/https proxies use customDomains instead of remotePort. The
// default domain is <name>.local; frps matches it by Host header.

View File

@ -101,3 +101,58 @@ func TestRenderTransportOmittedWhenEmpty(t *testing.T) {
t.Fatalf("proxy advanced should be empty, got %+v", cfg.Proxies[0])
}
}
func TestRenderHTTPAdvanced(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"}
data, err := Render(remote, []Proxy{
{Name: "web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 8080,
CustomDomains: []string{"app.example.com"},
Locations: []string{"/api", "/admin"},
HostHeaderRewrite: "backend.internal",
HTTPHeaders: map[string]string{"X-Custom": "val"},
BasicAuthUser: "user", BasicAuthPassword: "pass"},
})
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.CustomDomains[0] != "app.example.com" {
t.Fatalf("customDomains = %+v", p.CustomDomains)
}
if len(p.Locations) != 2 || p.Locations[0] != "/api" {
t.Fatalf("locations = %+v", p.Locations)
}
if p.HostHeaderRewrite != "backend.internal" {
t.Fatalf("hostHeaderRewrite = %q", p.HostHeaderRewrite)
}
if p.HTTPHeaders["X-Custom"] != "val" {
t.Fatalf("httpHeaders = %+v", p.HTTPHeaders)
}
if p.HTTPBasicAuth == nil || p.HTTPBasicAuth.User != "user" || p.HTTPBasicAuth.Password != "pass" {
t.Fatalf("basicAuth = %+v", p.HTTPBasicAuth)
}
}
func TestRenderHTTPSUsesCustomDomains(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
data, err := Render(remote, []Proxy{
{Name: "secure", Type: "https", LocalIP: "127.0.0.1", LocalPort: 8443,
CustomDomains: []string{"secure.example.com"}},
})
if err != nil {
t.Fatal(err)
}
var cfg Config
_ = json.Unmarshal(data, &cfg)
p := cfg.Proxies[0]
if p.RemotePort != 0 {
t.Fatalf("https proxy should not set remotePort, got %d", p.RemotePort)
}
if len(p.CustomDomains) != 1 || p.CustomDomains[0] != "secure.example.com" {
t.Fatalf("customDomains = %+v", p.CustomDomains)
}
}

View File

@ -27,6 +27,15 @@ type Local struct {
PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
// M2 HTTP/HTTPS routing (http/https only).
CustomDomains string `json:"customDomains,omitempty"` // comma-separated
SubDomain string `json:"subdomain,omitempty"`
Locations []string `json:"locations,omitempty"` // path routing
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
BasicAuthUser string `json:"basicAuthUser,omitempty"`
BasicAuthPassword string `json:"basicAuthPassword,omitempty"`
}
// Remote is a remote server node on the canvas.
@ -43,6 +52,10 @@ type Remote struct {
TransportTLS bool `json:"transportTls,omitempty"`
TransportPool int `json:"transportPool,omitempty"`
TransportTLSServerName string `json:"transportTlsServerName,omitempty"`
// M2: frps vhostHTTPPort (optional, displayed on the status page as the
// HTTP access port for http/https services).
VhostHTTPPort int `json:"vhostHttpPort,omitempty"`
}
// Link connects one local to one remote.
@ -148,12 +161,20 @@ func (s *Store) migrate() error {
"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 ''",
},
"remotes": {
"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",
},
}
for table, cols := range tables {
@ -191,7 +212,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 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 FROM locals ORDER BY name")
if err != nil {
return nil, err
}
@ -200,14 +221,16 @@ func (s *Store) ListLocals() ([]Local, error) {
for rows.Next() {
var l Local
var enc, comp int
var metaRaw, annoRaw string
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw); err != nil {
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 {
return nil, err
}
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
l.HTTPHeaders = decodeMap(hdrRaw)
out = append(out, l)
}
return out, rows.Err()
@ -216,27 +239,53 @@ func (s *Store) ListLocals() ([]Local, error) {
func (s *Store) GetLocal(name string) (Local, bool) {
var l Local
var enc, comp int
var metaRaw, annoRaw string
row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations 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); err != nil {
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 {
return Local{}, false
}
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
l.HTTPHeaders = decodeMap(hdrRaw)
return l, true
}
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) 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",
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations),
"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,
)
return err
}
// encodeSlice serializes an optional []string as JSON (empty -> "").
func encodeSlice(s []string) string {
if len(s) == 0 {
return ""
}
b, err := json.Marshal(s)
if err != nil {
return ""
}
return string(b)
}
// decodeSlice reads a JSON []string column; empty/invalid -> nil.
func decodeSlice(raw string) []string {
if raw == "" {
return nil
}
var s []string
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return nil
}
return s
}
// encodeMap serializes an optional JSON map so nil stays an empty string.
func encodeMap(m map[string]string) string {
if len(m) == 0 {
@ -269,7 +318,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 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 FROM remotes ORDER BY name")
if err != nil {
return nil, err
}
@ -278,7 +327,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); 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); err != nil {
return nil, err
}
r.Enabled = en != 0
@ -291,8 +340,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 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); 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 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 {
return Remote{}, false
}
r.Enabled = en != 0
@ -302,9 +351,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) 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",
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName,
"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,
)
return err
}