mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-31 14:27:23 +00:00
- store.Remote 新增 user 字段(DB 列名 usr,避开保留字;migrate() 自动加列), 渲染为 frpc 全局 user。面板型 frps(如 ChhChongGou 实例)强制要求 user 登录, 缺失时服务端返回 panel authentication unavailable: panel HTTP status 403 - ★ 修复渲染 bug:useEncryption/useCompression/bandwidthLimit 必须内嵌在 proxy 的 transport 对象(frpc ProxyTransport)。此前渲染在 proxy 顶层,frpc 默认 --strict-config 直接拒载:unknown field "useEncryption"。任何启用加密/压缩 的转发都无法启动。poolCount 无 proxy 级表达(官方 ProxyTransport 无此字段), 保留在 store 但不再渲染 - 前端:RemoteNode 画布节点与 StatusView 表单加 user 输入(画布此前会静默 清掉未声明的字段),带『面板型 frps 必填』提示 - 测试:user 往返(含迁移路径)、渲染含/不含 user、transport 内嵌与顶层 非法键回归守卫
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package render
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"webui4frpc/internal/store"
|
|
)
|
|
|
|
func TestRenderTCPProxy(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: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var cfg Config
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.ServerAddr != "1.2.3.4" || cfg.Auth.Token != "secret" {
|
|
t.Fatalf("cfg = %+v", cfg)
|
|
}
|
|
if len(cfg.Proxies) != 1 || cfg.Proxies[0].RemotePort != 8080 {
|
|
t.Fatalf("proxies = %+v", cfg.Proxies)
|
|
}
|
|
}
|
|
|
|
func TestRenderDuplicateServiceGetsPortSuffix(t *testing.T) {
|
|
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
|
|
proxy := []Proxy{
|
|
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
|
|
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8081},
|
|
}
|
|
data, err := Render(remote, proxy)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var cfg Config
|
|
_ = json.Unmarshal(data, &cfg)
|
|
if len(cfg.Proxies) != 2 {
|
|
t.Fatalf("proxies = %+v", cfg.Proxies)
|
|
}
|
|
if cfg.Proxies[1].Name != "web-8081" {
|
|
t.Fatalf("second proxy name = %q", cfg.Proxies[1].Name)
|
|
}
|
|
}
|
|
|
|
func TestRenderTransportAdvanced(t *testing.T) {
|
|
remote := store.Remote{
|
|
Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret",
|
|
TransportProtocol: "kcp", TransportTLS: true, TransportPool: 3, TransportTLSServerName: "frps.local",
|
|
}
|
|
data, err := Render(remote, []Proxy{
|
|
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080,
|
|
UseEncryption: true, UseCompression: true, BandwidthLimit: "1MB", PoolCount: 2,
|
|
Metadatas: map[string]string{"env": "prod"}, Annotations: map[string]string{"owner": "ops"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var cfg Config
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Transport.Protocol != "kcp" {
|
|
t.Fatalf("transport.protocol = %q, want kcp", cfg.Transport.Protocol)
|
|
}
|
|
if cfg.Transport.TLS == nil || !cfg.Transport.TLS.Enable || cfg.Transport.TLS.ServerName != "frps.local" {
|
|
t.Fatalf("transport.tls = %+v", cfg.Transport.TLS)
|
|
}
|
|
if cfg.Transport.PoolCount != 3 {
|
|
t.Fatalf("transport.poolCount = %d, want 3", cfg.Transport.PoolCount)
|
|
}
|
|
p := cfg.Proxies[0]
|
|
// useEncryption/useCompression/bandwidthLimit belong to the proxy's nested
|
|
// `transport` object. Asserting them on the proxy top level is what let the
|
|
// old renderer ship a config frpc rejects outright (`unknown field
|
|
// "useEncryption"` under --strict-config).
|
|
if p.Transport == nil {
|
|
t.Fatalf("proxy transport section missing: %+v", p)
|
|
}
|
|
if !p.Transport.UseEncryption || !p.Transport.UseCompression || p.Transport.BandwidthLimit != "1MB" {
|
|
t.Fatalf("proxy transport = %+v", p.Transport)
|
|
}
|
|
if p.Metadatas["env"] != "prod" || p.Annotations["owner"] != "ops" {
|
|
t.Fatalf("proxy metadatas/annotations = %+v", p)
|
|
}
|
|
// Regression guard: the top level must carry no encryption/pool keys, since
|
|
// frpc runs with --strict-config and rejects unknown fields.
|
|
var raw struct {
|
|
Proxies []map[string]any `json:"proxies"`
|
|
}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, bad := range []string{"useEncryption", "useCompression", "poolCount"} {
|
|
if _, ok := raw.Proxies[0][bad]; ok {
|
|
t.Fatalf("proxy top level must not carry %q: %s", bad, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderTransportOmittedWhenEmpty(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
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Transport.Protocol != "" || cfg.Transport.TLS != nil || cfg.Transport.PoolCount != 0 {
|
|
t.Fatalf("transport should be empty, got %+v", cfg.Transport)
|
|
}
|
|
if cfg.Proxies[0].Transport != nil {
|
|
t.Fatalf("proxy transport should be empty when no proxy-level knobs are set, got %+v", cfg.Proxies[0].Transport)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A panel-managed frps refuses logins without `user`, so the rendered config
|
|
// must carry it verbatim when set.
|
|
func TestRenderEmitsUserWhenSet(t *testing.T) {
|
|
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "tok", User: "e8f52adfaf0e5c95"}
|
|
data, err := Render(remote, []Proxy{
|
|
{Name: "club", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 80, RemotePort: 62962},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var cfg Config
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.User != "e8f52adfaf0e5c95" {
|
|
t.Fatalf("user = %q, want e8f52adfaf0e5c95", cfg.User)
|
|
}
|
|
// The key must be present on the wire, not merely in the parsed struct:
|
|
// an omitempty regression would silently break panel-type frps again.
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := raw["user"]; !ok {
|
|
t.Fatalf("rendered JSON has no user key: %s", data)
|
|
}
|
|
}
|
|
|
|
// Plain frps rejects unknown fields, so an empty user must be omitted entirely.
|
|
func TestRenderOmitsUserWhenEmpty(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 raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := raw["user"]; ok {
|
|
t.Fatalf("empty user must be omitted, got: %s", data)
|
|
}
|
|
}
|