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

View File

@ -32,6 +32,13 @@ type Proxy struct {
PoolCount int PoolCount int
Metadatas map[string]string Metadatas map[string]string
Annotations 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. // frpcAuth mirrors frpc's auth section.
@ -53,6 +60,12 @@ type frpcTLS struct {
ServerName string `json:"serverName,omitempty"` 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 // frpcProxy is the serialized proxy object. Different types use different
// fields; empty ones are omitted so the JSON is accepted by frpc. // fields; empty ones are omitted so the JSON is accepted by frpc.
type frpcProxy struct { type frpcProxy struct {
@ -71,6 +84,12 @@ type frpcProxy struct {
PoolCount int `json:"poolCount,omitempty"` PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"` Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,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. // 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) nameCounts := make(map[string]int)
for _, p := range proxies { for _, p := range proxies {
frpcP := frpcProxy{ frpcP := frpcProxy{
Name: p.Name, Name: p.Name,
Type: p.Type, Type: p.Type,
LocalIP: p.LocalIP, LocalIP: p.LocalIP,
LocalPort: p.LocalPort, LocalPort: p.LocalPort,
SubDomain: p.SubDomain, SubDomain: p.SubDomain,
UseEncryption: p.UseEncryption, UseEncryption: p.UseEncryption,
UseCompression: p.UseCompression, UseCompression: p.UseCompression,
BandwidthLimit: p.BandwidthLimit, BandwidthLimit: p.BandwidthLimit,
PoolCount: p.PoolCount, PoolCount: p.PoolCount,
Metadatas: p.Metadatas, Metadatas: p.Metadatas,
Annotations: p.Annotations, 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 // http/https proxies use customDomains instead of remotePort. The
// default domain is <name>.local; frps matches it by Host header. // 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]) 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"` PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"` Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,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. // Remote is a remote server node on the canvas.
@ -43,6 +52,10 @@ type Remote struct {
TransportTLS bool `json:"transportTls,omitempty"` TransportTLS bool `json:"transportTls,omitempty"`
TransportPool int `json:"transportPool,omitempty"` TransportPool int `json:"transportPool,omitempty"`
TransportTLSServerName string `json:"transportTlsServerName,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. // Link connects one local to one remote.
@ -148,12 +161,20 @@ func (s *Store) migrate() error {
"pool_count INTEGER NOT NULL DEFAULT 0", "pool_count INTEGER NOT NULL DEFAULT 0",
"metadatas TEXT NOT NULL DEFAULT ''", "metadatas TEXT NOT NULL DEFAULT ''",
"annotations 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": { "remotes": {
"transport_protocol TEXT NOT NULL DEFAULT ''", "transport_protocol TEXT NOT NULL DEFAULT ''",
"transport_tls INTEGER NOT NULL DEFAULT 0", "transport_tls INTEGER NOT NULL DEFAULT 0",
"transport_pool INTEGER NOT NULL DEFAULT 0", "transport_pool INTEGER NOT NULL DEFAULT 0",
"transport_tls_server_name TEXT NOT NULL DEFAULT ''", "transport_tls_server_name TEXT NOT NULL DEFAULT ''",
"vhost_http_port INTEGER NOT NULL DEFAULT 0",
}, },
} }
for table, cols := range tables { for table, cols := range tables {
@ -191,7 +212,7 @@ func (s *Store) Close() error { return s.db.Close() }
// ---- Locals ---- // ---- Locals ----
func (s *Store) ListLocals() ([]Local, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -200,14 +221,16 @@ func (s *Store) ListLocals() ([]Local, error) {
for rows.Next() { for rows.Next() {
var l Local var l Local
var enc, comp int var enc, comp int
var metaRaw, annoRaw string 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); 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); err != nil {
return nil, err return nil, err
} }
l.UseEncryption = enc != 0 l.UseEncryption = enc != 0
l.UseCompression = comp != 0 l.UseCompression = comp != 0
l.Metadatas = decodeMap(metaRaw) l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw) l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
l.HTTPHeaders = decodeMap(hdrRaw)
out = append(out, l) out = append(out, l)
} }
return out, rows.Err() return out, rows.Err()
@ -216,27 +239,53 @@ func (s *Store) ListLocals() ([]Local, error) {
func (s *Store) GetLocal(name string) (Local, bool) { func (s *Store) GetLocal(name string) (Local, bool) {
var l Local var l Local
var enc, comp int var enc, comp int
var metaRaw, annoRaw string 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 FROM locals WHERE name = ?", name) 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); err != nil { 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 return Local{}, false
} }
l.UseEncryption = enc != 0 l.UseEncryption = enc != 0
l.UseCompression = comp != 0 l.UseCompression = comp != 0
l.Metadatas = decodeMap(metaRaw) l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw) l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
l.HTTPHeaders = decodeMap(hdrRaw)
return l, true return l, true
} }
func (s *Store) UpsertLocal(l Local) error { func (s *Store) UpsertLocal(l Local) error {
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations) VALUES(?,?,?,?,?,?,?,?,?,?) "+ "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", "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.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 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. // encodeMap serializes an optional JSON map so nil stays an empty string.
func encodeMap(m map[string]string) string { func encodeMap(m map[string]string) string {
if len(m) == 0 { if len(m) == 0 {
@ -269,7 +318,7 @@ func (s *Store) DeleteLocal(name string) error {
// ---- Remotes ---- // ---- Remotes ----
func (s *Store) ListRemotes() ([]Remote, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -278,7 +327,7 @@ func (s *Store) ListRemotes() ([]Remote, error) {
for rows.Next() { for rows.Next() {
var r Remote var r Remote
var en, tls int 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 return nil, err
} }
r.Enabled = en != 0 r.Enabled = en != 0
@ -291,8 +340,8 @@ func (s *Store) ListRemotes() ([]Remote, error) {
func (s *Store) GetRemote(name string) (Remote, bool) { func (s *Store) GetRemote(name string) (Remote, bool) {
var r Remote var r Remote
var en, tls int 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) 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); err != nil { 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 return Remote{}, false
} }
r.Enabled = en != 0 r.Enabled = en != 0
@ -302,9 +351,9 @@ func (s *Store) GetRemote(name string) (Remote, bool) {
func (s *Store) UpsertRemote(r Remote) error { func (s *Store) UpsertRemote(r Remote) error {
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name) VALUES(?,?,?,?,?,?,?,?,?,?) "+ "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", "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.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 return err
} }

View File

@ -48,10 +48,10 @@
### M2 HTTP/HTTPS 完善 ### M2 HTTP/HTTPS 完善
- [ ] local http/https 增加可编辑域名字段customDomains / subdomain - [x] local http/https 增加可编辑域名字段customDomains / subdomain
- [ ] locations路径路由多配置 - [x] locations路径路由多配置
- [ ] httpHeaderRewrite / hostHeaderRewrite / basicAuth站点访问认证 - [x] httpHeaderRewrite / hostHeaderRewrite / basicAuth站点访问认证
- [ ] frps vhostHTTPPort 的显示与状态映射(状态页展示) - [x] frps vhostHTTPPort 的显示与状态映射(状态页展示)
### M3 负载均衡与健康检查 ### M3 负载均衡与健康检查

View File

@ -46,6 +46,15 @@
<label>连接池 <input v-model.number="poolCountField" type="number" min="0" /></label> <label>连接池 <input v-model.number="poolCountField" type="number" min="0" /></label>
<label>元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label> <label>元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label>
<label>注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label> <label>注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label>
<div class="adv-sep">HTTP/HTTPS 路由</div>
<label>域名(逗号分隔) <input v-model="customDomainsField" placeholder="app.example.com" /></label>
<label>子域 <input v-model="subdomainField" placeholder="app (需 frps subdomain_host)" /></label>
<label>路径路由(JSON) <input v-model="locationsField" placeholder='["/api","/admin"]' /></label>
<label>Host 改写 <input v-model="hostHeaderRewriteField" placeholder="backend.internal" /></label>
<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> </div>
</div> </div>
</div> </div>
@ -114,6 +123,44 @@ const annotationsField = computed({
emit('update:data', { ...props.data, annotations: parsed }) emit('update:data', { ...props.data, annotations: parsed })
}, },
}) })
const customDomainsField = field('customDomains')
const subdomainField = field('subdomain')
const hostHeaderRewriteField = field('hostHeaderRewrite')
const basicAuthUserField = field('basicAuthUser')
const basicAuthPasswordField = field('basicAuthPassword')
// locations is an array shown as JSON in the UI
const locationsField = computed({
get: () => JSON.stringify(props.data.locations ?? []),
set: (v: string) => {
let parsed: string[] = []
if (v.trim()) {
try {
const arr = JSON.parse(v)
if (Array.isArray(arr)) parsed = arr.map(String)
else return
} catch {
return
}
}
emit('update:data', { ...props.data, locations: parsed })
},
})
// httpHeaders is a map shown as JSON
const httpHeadersField = computed({
get: () => JSON.stringify(props.data.httpHeaders ?? {}),
set: (v: string) => {
let parsed: Record<string, string> = {}
if (v.trim()) {
try {
parsed = JSON.parse(v)
} catch {
return
}
}
emit('update:data', { ...props.data, httpHeaders: parsed })
},
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -45,6 +45,8 @@
</label> </label>
<label>TLS SNI <input v-model="transportTlsServerNameField" placeholder="frps.local" /></label> <label>TLS SNI <input v-model="transportTlsServerNameField" placeholder="frps.local" /></label>
<label>连接池 <input v-model.number="transportPoolField" type="number" min="0" /></label> <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>
</div> </div>
</div> </div>
</div> </div>
@ -84,6 +86,7 @@ const transportProtocolField = field('transportProtocol')
const transportTlsField = field('transportTls') const transportTlsField = field('transportTls')
const transportTlsServerNameField = field('transportTlsServerName') const transportTlsServerNameField = field('transportTlsServerName')
const transportPoolField = field('transportPool') const transportPoolField = field('transportPool')
const vhostHttpPortField = field('vhostHttpPort')
const showAdv = ref(false) const showAdv = ref(false)
</script> </script>

View File

@ -2,109 +2,121 @@
// CanvasLocal / CanvasRemote are aliases kept for node components migrated // CanvasLocal / CanvasRemote are aliases kept for node components migrated
// from the original prototype; they equal Local / Remote. // from the original prototype; they equal Local / Remote.
export type CanvasLocal = Local export type CanvasLocal = Local;
export type CanvasRemote = Remote export type CanvasRemote = Remote;
// CanvasLink mirrors the store.Link JSON shape used by the canvas editor. // CanvasLink mirrors the store.Link JSON shape used by the canvas editor.
export type CanvasLink = Link export type CanvasLink = Link;
export interface Local { export interface Local {
name: string name: string;
ip: string ip: string;
port: number port: number;
protocol: string // tcp | udp | http | https protocol: string; // tcp | udp | http | https
// M1 advanced transport knobs (optional; empty/zero = frpc defaults) // M1 advanced transport knobs (optional; empty/zero = frpc defaults)
useEncryption?: boolean useEncryption?: boolean;
useCompression?: boolean useCompression?: boolean;
bandwidthLimit?: string bandwidthLimit?: string;
poolCount?: number poolCount?: number;
metadatas?: Record<string, string> metadatas?: Record<string, string>;
annotations?: Record<string, string> annotations?: Record<string, string>;
// M2 HTTP/HTTPS routing (http/https only)
customDomains?: string; // comma-separated
subdomain?: string;
locations?: string[];
hostHeaderRewrite?: string;
httpHeaders?: Record<string, string>;
basicAuthUser?: string;
basicAuthPassword?: string;
} }
export interface Remote { export interface Remote {
name: string name: string;
ip: string ip: string;
port: number // frps connect port port: number; // frps connect port
token?: string token?: string;
url?: string url?: string;
enabled: boolean enabled: boolean;
// M1 transport section // M1 transport section
transportProtocol?: string // tcp | quic | kcp | websocket transportProtocol?: string; // tcp | quic | kcp | websocket
transportTls?: boolean transportTls?: boolean;
transportPool?: number transportPool?: number;
transportTlsServerName?: string transportTlsServerName?: string;
// M2: frps vhostHTTPPort
vhostHttpPort?: number;
} }
export interface Link { export interface Link {
id?: number id?: number;
local: string local: string;
remote: string remote: string;
remotePort: number remotePort: number;
offsetX?: number offsetX?: number;
offsetY?: number offsetY?: number;
} }
export interface CanvasData { export interface CanvasData {
locals: Local[] locals: Local[];
remotes: Remote[] remotes: Remote[];
links: Link[] links: Link[];
} }
export interface Settings { export interface Settings {
autoStartProfiles: boolean autoStartProfiles: boolean;
restartOnExit: boolean restartOnExit: boolean;
restartIntervalSeconds: number restartIntervalSeconds: number;
binaryPath?: string binaryPath?: string;
} }
export interface ProcessStatus { export interface ProcessStatus {
state: string state: string;
pid?: number pid?: number;
startTime?: number startTime?: number;
restartCount: number restartCount: number;
exitCode?: number exitCode?: number;
err?: string err?: string;
} }
export interface StatusProfile { export interface StatusProfile {
name: string name: string;
enabled: boolean enabled: boolean;
process: ProcessStatus process: ProcessStatus;
hasProcess: boolean hasProcess: boolean;
forwards: { service: string; remotePort: number; localPort?: number }[] forwards: { service: string; remotePort: number; localPort?: number }[];
} }
export interface StatusResp { export interface StatusResp {
version: string version: string;
workDir: string workDir: string;
settings: Settings settings: Settings;
services: Local[] services: Local[];
remotes: Remote[] remotes: Remote[];
binaryPath: string binaryPath: string;
profiles: StatusProfile[] profiles: StatusProfile[];
localStatus: LocalStatus[] localStatus: LocalStatus[];
} }
export interface LocalTargetStatus { export interface LocalTargetStatus {
remote: string remote: string;
remotePort: number remotePort: number;
workerState: string workerState: string;
} }
export interface LocalStatus { export interface LocalStatus {
local: Local local: Local;
targets: LocalTargetStatus[] targets: LocalTargetStatus[];
} }
export interface BinaryStatus { export interface BinaryStatus {
binaryPath: string binaryPath: string;
version?: string version?: string;
} }
export interface InstallResult { export interface InstallResult {
path: string path: string;
version: string version: string;
} }

View File

@ -33,6 +33,9 @@
<div class="ns-meta"> <div class="ns-meta">
<span v-if="p.process.pid">pid {{ p.process.pid }}</span> <span v-if="p.process.pid">pid {{ p.process.pid }}</span>
<span class="ns-err" v-else-if="p.process.err">{{ p.process.err }}</span> <span class="ns-err" v-else-if="p.process.err">{{ p.process.err }}</span>
<span v-if="remoteVhostPort(p.name)" class="ns-vhost">
HTTP 访问端口 :{{ remoteVhostPort(p.name) }}
</span>
</div> </div>
<div class="ns-forwards" v-if="p.forwards.length"> <div class="ns-forwards" v-if="p.forwards.length">
<span v-for="f in p.forwards" :key="f.service" class="ns-fwd"> <span v-for="f in p.forwards" :key="f.service" class="ns-fwd">
@ -218,6 +221,10 @@ const workerStateLabel = (s?: string) => {
const workerHealthy = (s?: string) => s === 'running' const workerHealthy = (s?: string) => s === 'running'
// remoteVhostPort returns the frps vhostHTTPPort for a remote (0 if unset).
const remoteVhostPort = (name: string): number =>
status.value?.remotes.find((r) => r.name === name)?.vhostHttpPort || 0
onMounted(() => { onMounted(() => {
loadStatus() loadStatus()
timer = window.setInterval(loadStatus, 5000) timer = window.setInterval(loadStatus, 5000)
@ -402,6 +409,15 @@ onBeforeUnmount(() => {
color: $color-danger; color: $color-danger;
} }
.ns-vhost {
margin-left: 8px;
background: #ecf5ff;
color: #409eff;
border-radius: 6px;
padding: 1px 8px;
font-size: 12px;
}
.ns-forwards { .ns-forwards {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;