mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-21 09:27:57 +00:00
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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user