diff --git a/server/http/controller_v2.go b/server/http/controller_v2.go index 9b2e241b..dfa7b495 100644 --- a/server/http/controller_v2.go +++ b/server/http/controller_v2.go @@ -17,6 +17,7 @@ package http import ( "cmp" "fmt" + "maps" "math" "net/http" "net/url" @@ -255,7 +256,7 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) { } slices.SortFunc(items, func(a, b model.V2ProxyResp) int { - if v := cmp.Compare(a.Type, b.Type); v != 0 { + if v := cmp.Compare(a.Spec.Type, b.Spec.Type); v != 0 { return v } return cmp.Compare(a.Name, b.Name) @@ -435,26 +436,36 @@ func matchV2ClientQuery(item model.ClientInfoResp, q string) bool { func matchV2ProxyQuery(item model.V2ProxyResp, q string) bool { values := []string{ item.Name, - item.Type, + item.Spec.Type, item.User, item.ClientID, item.Status.State, } - switch spec := item.Spec.(type) { - case *model.TCPOutConf: - values = append(values, strconv.Itoa(spec.RemotePort)) - case *model.UDPOutConf: - values = append(values, strconv.Itoa(spec.RemotePort)) - case *model.HTTPOutConf: - values = append(values, spec.CustomDomains...) - values = append(values, spec.SubDomain) - case *model.HTTPSOutConf: - values = append(values, spec.CustomDomains...) - values = append(values, spec.SubDomain) - case *model.TCPMuxOutConf: - values = append(values, spec.CustomDomains...) - values = append(values, spec.SubDomain) + switch item.Spec.Type { + case string(v1.ProxyTypeTCP): + if item.Spec.TCP != nil && item.Spec.TCP.RemotePort != nil { + values = append(values, strconv.Itoa(*item.Spec.TCP.RemotePort)) + } + case string(v1.ProxyTypeUDP): + if item.Spec.UDP != nil && item.Spec.UDP.RemotePort != nil { + values = append(values, strconv.Itoa(*item.Spec.UDP.RemotePort)) + } + case string(v1.ProxyTypeHTTP): + if item.Spec.HTTP != nil { + values = append(values, item.Spec.HTTP.CustomDomains...) + values = append(values, item.Spec.HTTP.Subdomain) + } + case string(v1.ProxyTypeHTTPS): + if item.Spec.HTTPS != nil { + values = append(values, item.Spec.HTTPS.CustomDomains...) + values = append(values, item.Spec.HTTPS.Subdomain) + } + case string(v1.ProxyTypeTCPMUX): + if item.Spec.TCPMux != nil { + values = append(values, item.Spec.TCPMux.CustomDomains...) + values = append(values, item.Spec.TCPMux.Subdomain) + } } return containsV2Query(q, values...) @@ -526,20 +537,19 @@ func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2Clien func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp { state := "offline" - var spec any + var cfg v1.ProxyConfigurer if c.pxyManager != nil { if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { state = "online" - spec = getConfFromConfigurer(pxy.GetConfigurer()) + cfg = pxy.GetConfigurer() } } return model.V2ProxyResp{ Name: ps.Name, - Type: ps.Type, User: ps.User, ClientID: ps.ClientID, - Spec: spec, + Spec: buildV2ProxySpec(ps.Type, cfg), Status: model.V2ProxyStatusResp{ State: state, TodayTrafficIn: ps.TodayTrafficIn, @@ -550,3 +560,88 @@ func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp { }, } } + +func buildV2ProxySpec(proxyType string, cfg v1.ProxyConfigurer) model.V2ProxySpec { + spec := model.V2ProxySpec{Type: proxyType} + + switch proxyType { + case string(v1.ProxyTypeTCP): + block := &model.V2TCPProxySpec{} + if c, ok := cfg.(*v1.TCPProxyConfig); ok { + block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig()) + block.RemotePort = &c.RemotePort + } + spec.TCP = block + case string(v1.ProxyTypeUDP): + block := &model.V2UDPProxySpec{} + if c, ok := cfg.(*v1.UDPProxyConfig); ok { + block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig()) + block.RemotePort = &c.RemotePort + } + spec.UDP = block + case string(v1.ProxyTypeHTTP): + block := &model.V2HTTPProxySpec{} + if c, ok := cfg.(*v1.HTTPProxyConfig); ok { + block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig()) + block.CustomDomains = slices.Clone(c.CustomDomains) + block.Subdomain = c.SubDomain + block.Locations = slices.Clone(c.Locations) + block.HostHeaderRewrite = c.HostHeaderRewrite + } + spec.HTTP = block + case string(v1.ProxyTypeHTTPS): + block := &model.V2HTTPSProxySpec{} + if c, ok := cfg.(*v1.HTTPSProxyConfig); ok { + block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig()) + block.CustomDomains = slices.Clone(c.CustomDomains) + block.Subdomain = c.SubDomain + } + spec.HTTPS = block + case string(v1.ProxyTypeTCPMUX): + block := &model.V2TCPMuxProxySpec{} + if c, ok := cfg.(*v1.TCPMuxProxyConfig); ok { + block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig()) + block.CustomDomains = slices.Clone(c.CustomDomains) + block.Subdomain = c.SubDomain + block.Multiplexer = c.Multiplexer + block.RouteByHTTPUser = c.RouteByHTTPUser + } + spec.TCPMux = block + case string(v1.ProxyTypeSTCP): + block := &model.V2STCPProxySpec{} + if c, ok := cfg.(*v1.STCPProxyConfig); ok { + block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig()) + } + spec.STCP = block + case string(v1.ProxyTypeSUDP): + block := &model.V2SUDPProxySpec{} + if c, ok := cfg.(*v1.SUDPProxyConfig); ok { + block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig()) + } + spec.SUDP = block + case string(v1.ProxyTypeXTCP): + block := &model.V2XTCPProxySpec{} + if c, ok := cfg.(*v1.XTCPProxyConfig); ok { + block.V2ProxyBaseSpec = buildV2ProxyBaseSpec(c.GetBaseConfig()) + } + spec.XTCP = block + } + + return spec +} + +func buildV2ProxyBaseSpec(base *v1.ProxyBaseConfig) model.V2ProxyBaseSpec { + return model.V2ProxyBaseSpec{ + Annotations: maps.Clone(base.Annotations), + Metadatas: maps.Clone(base.Metadatas), + Transport: &model.V2ProxyTransportSpec{ + UseEncryption: base.Transport.UseEncryption, + UseCompression: base.Transport.UseCompression, + BandwidthLimit: base.Transport.BandwidthLimit.String(), + BandwidthLimitMode: base.Transport.BandwidthLimitMode, + }, + LoadBalancer: &model.V2ProxyLoadBalancerSpec{ + Group: base.LoadBalancer.Group, + }, + } +} diff --git a/server/http/controller_v2_proxy_spec_test.go b/server/http/controller_v2_proxy_spec_test.go new file mode 100644 index 00000000..82229bd7 --- /dev/null +++ b/server/http/controller_v2_proxy_spec_test.go @@ -0,0 +1,393 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http + +import ( + "encoding/json" + "strings" + "testing" + + configtypes "github.com/fatedier/frp/pkg/config/types" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/metrics/mem" + "github.com/fatedier/frp/server/http/model" +) + +func TestBuildV2ProxySpecAllTypesAndRedaction(t *testing.T) { + tests := []struct { + proxyType string + cfg v1.ProxyConfigurer + blockKeys []string + }{ + { + proxyType: "tcp", + cfg: &v1.TCPProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcp"), + RemotePort: 6000, + }, + blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"}, + }, + { + proxyType: "udp", + cfg: &v1.UDPProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"), + RemotePort: 7000, + }, + blockKeys: []string{"annotations", "loadBalancer", "metadatas", "remotePort", "transport"}, + }, + { + proxyType: "http", + cfg: &v1.HTTPProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "http"), + DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}, SubDomain: "app"}, + Locations: []string{"/api"}, + HTTPUser: "secret-http-user", + HTTPPassword: "secret-http-password", + HostHeaderRewrite: "backend.example.com", + RequestHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-request-header"}}, + ResponseHeaders: v1.HeaderOperations{Set: map[string]string{"X-Secret": "secret-response-header"}}, + RouteByHTTPUser: "secret-http-route-user", + }, + blockKeys: []string{"annotations", "customDomains", "hostHeaderRewrite", "loadBalancer", "locations", "metadatas", "subdomain", "transport"}, + }, + { + proxyType: "https", + cfg: &v1.HTTPSProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "https"), + DomainConfig: v1.DomainConfig{CustomDomains: []string{"secure.example.com"}, SubDomain: "secure"}, + }, + blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "subdomain", "transport"}, + }, + { + proxyType: "tcpmux", + cfg: &v1.TCPMuxProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "tcpmux"), + DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}, SubDomain: "mux"}, + HTTPUser: strings.Join([]string{"secret", "mux-http-user"}, "-"), + HTTPPassword: strings.Join([]string{"secret", "mux-http-password"}, "-"), + RouteByHTTPUser: "displayed-mux-user", + Multiplexer: "httpconnect", + }, + blockKeys: []string{"annotations", "customDomains", "loadBalancer", "metadatas", "multiplexer", "routeByHTTPUser", "subdomain", "transport"}, + }, + { + proxyType: "stcp", + cfg: &v1.STCPProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "stcp"), + Secretkey: strings.Join([]string{"secret", "stcp-key"}, "-"), + AllowUsers: []string{strings.Join([]string{"secret", "stcp-user"}, "-")}, + }, + blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"}, + }, + { + proxyType: "sudp", + cfg: &v1.SUDPProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "sudp"), + Secretkey: strings.Join([]string{"secret", "sudp-key"}, "-"), + AllowUsers: []string{strings.Join([]string{"secret", "sudp-user"}, "-")}, + }, + blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"}, + }, + { + proxyType: "xtcp", + cfg: &v1.XTCPProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "xtcp"), + Secretkey: strings.Join([]string{"secret", "xtcp-key"}, "-"), + AllowUsers: []string{strings.Join([]string{"secret", "xtcp-user"}, "-")}, + }, + blockKeys: []string{"annotations", "loadBalancer", "metadatas", "transport"}, + }, + } + + for _, tt := range tests { + t.Run(tt.proxyType, func(t *testing.T) { + spec := buildV2ProxySpec(tt.proxyType, tt.cfg) + raw := mustMarshalJSON(t, spec) + + var specObject map[string]json.RawMessage + if err := json.Unmarshal(raw, &specObject); err != nil { + t.Fatalf("unmarshal spec failed: %v", err) + } + assertRawJSONKeys(t, specObject, tt.proxyType, "type") + + var gotType string + if err := json.Unmarshal(specObject["type"], &gotType); err != nil { + t.Fatalf("unmarshal spec type failed: %v", err) + } + if gotType != tt.proxyType { + t.Fatalf("spec type mismatch, want %q got %q", tt.proxyType, gotType) + } + + var block map[string]json.RawMessage + if err := json.Unmarshal(specObject[tt.proxyType], &block); err != nil { + t.Fatalf("unmarshal active block failed: %v", err) + } + assertRawJSONKeys(t, block, tt.blockKeys...) + assertV2ProxyCommonSpec(t, block) + assertV2ProxyTypeFields(t, tt.proxyType, specObject[tt.proxyType]) + assertNoV2ProxySensitiveFields(t, block) + + content := string(raw) + for _, secret := range []string{ + "secret-proxy-name", + "secret-group-key", + "secret-local-host", + "secret-plugin-user", + "secret-plugin-password", + "secret-health-path", + "secret-http-user", + "secret-http-password", + "secret-request-header", + "secret-response-header", + "secret-http-route-user", + "secret-mux-http-user", + "secret-mux-http-password", + "secret-stcp-key", + "secret-stcp-user", + "secret-sudp-key", + "secret-sudp-user", + "secret-xtcp-key", + "secret-xtcp-user", + } { + if strings.Contains(content, secret) { + t.Fatalf("sensitive value %q leaked in spec: %s", secret, content) + } + } + }) + } +} + +func assertV2ProxyTypeFields(t *testing.T, proxyType string, raw json.RawMessage) { + t.Helper() + + switch proxyType { + case "tcp": + var block model.V2TCPProxySpec + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatalf("unmarshal tcp block failed: %v", err) + } + if block.RemotePort == nil || *block.RemotePort != 6000 { + t.Fatalf("tcp remote port mismatch: %#v", block.RemotePort) + } + case "udp": + var block model.V2UDPProxySpec + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatalf("unmarshal udp block failed: %v", err) + } + if block.RemotePort == nil || *block.RemotePort != 7000 { + t.Fatalf("udp remote port mismatch: %#v", block.RemotePort) + } + case "http": + var block model.V2HTTPProxySpec + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatalf("unmarshal http block failed: %v", err) + } + if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "app.example.com" || + block.Subdomain != "app" || len(block.Locations) != 1 || block.Locations[0] != "/api" || + block.HostHeaderRewrite != "backend.example.com" { + t.Fatalf("http fields mismatch: %#v", block) + } + case "https": + var block model.V2HTTPSProxySpec + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatalf("unmarshal https block failed: %v", err) + } + if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "secure.example.com" || block.Subdomain != "secure" { + t.Fatalf("https fields mismatch: %#v", block) + } + case "tcpmux": + var block model.V2TCPMuxProxySpec + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatalf("unmarshal tcpmux block failed: %v", err) + } + if len(block.CustomDomains) != 1 || block.CustomDomains[0] != "mux.example.com" || + block.Subdomain != "mux" || block.Multiplexer != "httpconnect" || block.RouteByHTTPUser != "displayed-mux-user" { + t.Fatalf("tcpmux fields mismatch: %#v", block) + } + } +} + +func TestBuildV2ProxyRespOfflineTypedShells(t *testing.T) { + for _, proxyType := range apiV2ProxyTypes { + t.Run(proxyType, func(t *testing.T) { + resp := (&Controller{}).buildV2ProxyResp(&mem.ProxyStats{ + Name: "offline-" + proxyType, + Type: proxyType, + }) + if resp.Status.State != "offline" { + t.Fatalf("offline phase mismatch: %#v", resp.Status) + } + + var specObject map[string]json.RawMessage + if err := json.Unmarshal(mustMarshalJSON(t, resp.Spec), &specObject); err != nil { + t.Fatalf("unmarshal offline spec failed: %v", err) + } + assertRawJSONKeys(t, specObject, proxyType, "type") + assertRawJSONKeysFromMessage(t, specObject[proxyType]) + }) + } +} + +func TestBuildV2ProxySpecDoesNotPopulateMismatchedBlock(t *testing.T) { + spec := buildV2ProxySpec("tcp", &v1.UDPProxyConfig{ + ProxyBaseConfig: newV2ProxyTestBaseConfig(t, "udp"), + RemotePort: 7000, + }) + + var specObject map[string]json.RawMessage + if err := json.Unmarshal(mustMarshalJSON(t, spec), &specObject); err != nil { + t.Fatalf("unmarshal mismatched spec failed: %v", err) + } + assertRawJSONKeys(t, specObject, "tcp", "type") + assertRawJSONKeysFromMessage(t, specObject["tcp"]) +} + +func newV2ProxyTestBaseConfig(t *testing.T, proxyType string) v1.ProxyBaseConfig { + t.Helper() + + bandwidthLimit, err := configtypes.NewBandwidthQuantity("10MB") + if err != nil { + t.Fatalf("create bandwidth limit failed: %v", err) + } + enabled := false + return v1.ProxyBaseConfig{ + Name: "secret-proxy-name", + Type: proxyType, + Enabled: &enabled, + Annotations: map[string]string{"annotation-key": "annotation-value"}, + Metadatas: map[string]string{"metadata-key": "metadata-value"}, + Transport: v1.ProxyTransport{ + UseEncryption: true, + UseCompression: true, + BandwidthLimit: bandwidthLimit, + BandwidthLimitMode: configtypes.BandwidthLimitModeServer, + ProxyProtocolVersion: "v2", + }, + LoadBalancer: v1.LoadBalancerConfig{ + Group: "public-group", + GroupKey: "secret-group-key", + }, + HealthCheck: v1.HealthCheckConfig{ + Type: "http", + Path: "secret-health-path", + }, + ProxyBackend: v1.ProxyBackend{ + LocalIP: "secret-local-host", + LocalPort: 8080, + Plugin: v1.TypedClientPluginOptions{ + Type: v1.PluginHTTPProxy, + ClientPluginOptions: &v1.HTTPProxyPluginOptions{ + Type: v1.PluginHTTPProxy, + HTTPUser: "secret-plugin-user", + HTTPPassword: "secret-plugin-password", + }, + }, + }, + } +} + +func assertV2ProxyCommonSpec(t *testing.T, block map[string]json.RawMessage) { + t.Helper() + + var annotations map[string]string + if err := json.Unmarshal(block["annotations"], &annotations); err != nil { + t.Fatalf("unmarshal annotations failed: %v", err) + } + if annotations["annotation-key"] != "annotation-value" { + t.Fatalf("annotations mismatch: %#v", annotations) + } + + var metadatas map[string]string + if err := json.Unmarshal(block["metadatas"], &metadatas); err != nil { + t.Fatalf("unmarshal metadatas failed: %v", err) + } + if metadatas["metadata-key"] != "metadata-value" { + t.Fatalf("metadatas mismatch: %#v", metadatas) + } + + assertRawJSONKeysFromMessage(t, block["transport"], + "bandwidthLimit", + "bandwidthLimitMode", + "useCompression", + "useEncryption", + ) + var transport model.V2ProxyTransportSpec + if err := json.Unmarshal(block["transport"], &transport); err != nil { + t.Fatalf("unmarshal transport failed: %v", err) + } + if !transport.UseEncryption || !transport.UseCompression || + transport.BandwidthLimit != "10MB" || transport.BandwidthLimitMode != "server" { + t.Fatalf("transport mismatch: %#v", transport) + } + + assertRawJSONKeysFromMessage(t, block["loadBalancer"], "group") + var loadBalancer model.V2ProxyLoadBalancerSpec + if err := json.Unmarshal(block["loadBalancer"], &loadBalancer); err != nil { + t.Fatalf("unmarshal load balancer failed: %v", err) + } + if loadBalancer.Group != "public-group" { + t.Fatalf("load balancer mismatch: %#v", loadBalancer) + } +} + +func assertNoV2ProxySensitiveFields(t *testing.T, value any) { + t.Helper() + + forbidden := map[string]struct{}{ + "allowUsers": {}, + "enabled": {}, + "groupKey": {}, + "healthCheck": {}, + "httpPassword": {}, + "httpUser": {}, + "localIP": {}, + "localPort": {}, + "name": {}, + "natTraversal": {}, + "plugin": {}, + "proxyProtocolVersion": {}, + "requestHeaders": {}, + "responseHeaders": {}, + "secretKey": {}, + "type": {}, + } + + var walk func(any) + walk = func(current any) { + switch current := current.(type) { + case map[string]any: + for key, nested := range current { + if _, ok := forbidden[key]; ok { + t.Fatalf("sensitive field %q leaked in active block", key) + } + walk(nested) + } + case []any: + for _, nested := range current { + walk(nested) + } + } + } + + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal active block failed: %v", err) + } + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode active block failed: %v", err) + } + walk(decoded) +} diff --git a/server/http/controller_v2_test.go b/server/http/controller_v2_test.go index a2fc8b13..042cedbc 100644 --- a/server/http/controller_v2_test.go +++ b/server/http/controller_v2_test.go @@ -416,9 +416,17 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) { t.Fatalf("proxy filter total mismatch: %#v", proxyResp.Data) } proxyItem := proxyResp.Data.Items[0] - if proxyItem.Name != "tcp-empty" || proxyItem.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" { + if proxyItem.Name != "tcp-empty" || proxyItem.Spec.Type != "tcp" || proxyItem.User != "" || proxyItem.Status.State != "offline" { t.Fatalf("proxy item mismatch: %#v", proxyItem) } + rawProxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[map[string]json.RawMessage]]](t, resp) + assertRawJSONKeys(t, rawProxyResp.Data.Items[0], "clientID", "name", "spec", "status", "user") + var rawListSpec map[string]json.RawMessage + if err := json.Unmarshal(rawProxyResp.Data.Items[0]["spec"], &rawListSpec); err != nil { + t.Fatalf("unmarshal list proxy spec failed: %v", err) + } + assertRawJSONKeys(t, rawListSpec, "tcp", "type") + assertRawJSONKeysFromMessage(t, rawListSpec["tcp"]) resp = performRequest(router, "/api/v2/proxies/tcp-alice") rawProxyDetailResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp) @@ -434,6 +442,13 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) { if proxyDetailResp.Data.Name != "tcp-alice" || proxyDetailResp.Data.User != "alice" { t.Fatalf("proxy detail mismatch: %#v", proxyDetailResp.Data) } + assertRawJSONKeys(t, rawProxyDetailResp.Data, "clientID", "name", "spec", "status", "user") + var rawDetailSpec map[string]json.RawMessage + if err := json.Unmarshal(rawProxyDetailResp.Data["spec"], &rawDetailSpec); err != nil { + t.Fatalf("unmarshal detail proxy spec failed: %v", err) + } + assertRawJSONKeys(t, rawDetailSpec, "tcp", "type") + assertRawJSONKeysFromMessage(t, rawDetailSpec["tcp"]) if proxyDetailResp.Data.Status.LastStartAt != 1783504200 || proxyDetailResp.Data.Status.LastCloseAt != 1783504300 { t.Fatalf("proxy detail timestamp mismatch: %#v", proxyDetailResp.Data.Status) } @@ -598,66 +613,85 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) { }{ { name: "tcp remote port", - item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{ - RemotePort: 6000, + item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{ + Type: "tcp", + TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)}, }}, q: "6000", want: true, }, { name: "udp remote port", - item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{ - RemotePort: 7000, + item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{ + Type: "udp", + UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)}, }}, q: "7000", want: true, }, { name: "remote port does not match colon form", - item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{ - RemotePort: 6000, + item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{ + Type: "tcp", + TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)}, }}, q: ":6000", want: false, }, { name: "http custom domain", - item: model.V2ProxyResp{Name: "http-proxy", Type: "http", Spec: &model.HTTPOutConf{ - DomainConfig: v1.DomainConfig{CustomDomains: []string{"app.example.com"}}, + item: model.V2ProxyResp{Name: "http-proxy", Spec: model.V2ProxySpec{ + Type: "http", + HTTP: &model.V2HTTPProxySpec{CustomDomains: []string{"app.example.com"}}, }}, q: "app.example.com", want: true, }, { name: "https subdomain", - item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{ - DomainConfig: v1.DomainConfig{SubDomain: "portal"}, + item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{ + Type: "https", + HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"}, }}, q: "portal", want: true, }, { name: "subdomain does not match expanded host", - item: model.V2ProxyResp{Name: "https-proxy", Type: "https", Spec: &model.HTTPSOutConf{ - DomainConfig: v1.DomainConfig{SubDomain: "portal"}, + item: model.V2ProxyResp{Name: "https-proxy", Spec: model.V2ProxySpec{ + Type: "https", + HTTPS: &model.V2HTTPSProxySpec{Subdomain: "portal"}, }}, q: "portal.example.com", want: false, }, { name: "tcpmux custom domain", - item: model.V2ProxyResp{Name: "tcpmux-proxy", Type: "tcpmux", Spec: &model.TCPMuxOutConf{ - DomainConfig: v1.DomainConfig{CustomDomains: []string{"mux.example.com"}}, + item: model.V2ProxyResp{Name: "tcpmux-proxy", Spec: model.V2ProxySpec{ + Type: "tcpmux", + TCPMux: &model.V2TCPMuxProxySpec{CustomDomains: []string{"mux.example.com"}}, }}, q: "mux.example.com", want: true, }, { - name: "nil spec does not match spec fields", - item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil}, + name: "offline shell does not match online spec fields", + item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{ + Type: "tcp", + TCP: &model.V2TCPProxySpec{}, + }}, q: "6000", want: false, }, + { + name: "offline shell does not contribute zero remote port", + item: model.V2ProxyResp{Name: "offline-proxy", Spec: model.V2ProxySpec{ + Type: "tcp", + TCP: &model.V2TCPProxySpec{}, + }}, + q: "0", + want: false, + }, } for _, tt := range tests { @@ -733,6 +767,10 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) { } } +func v2TestIntPtr(value int) *int { + return &value +} + func newV2TestController(t *testing.T) *Controller { t.Helper() diff --git a/server/http/model/v2.go b/server/http/model/v2.go index e5c06802..383041b1 100644 --- a/server/http/model/v2.go +++ b/server/http/model/v2.go @@ -75,13 +75,87 @@ type V2ClientStatusResp struct { type V2ProxyResp struct { Name string `json:"name"` - Type string `json:"type"` User string `json:"user"` ClientID string `json:"clientID"` - Spec any `json:"spec"` + Spec V2ProxySpec `json:"spec"` Status V2ProxyStatusResp `json:"status"` } +type V2ProxySpec struct { + Type string `json:"type"` + + TCP *V2TCPProxySpec `json:"tcp,omitempty"` + UDP *V2UDPProxySpec `json:"udp,omitempty"` + HTTP *V2HTTPProxySpec `json:"http,omitempty"` + HTTPS *V2HTTPSProxySpec `json:"https,omitempty"` + TCPMux *V2TCPMuxProxySpec `json:"tcpmux,omitempty"` + STCP *V2STCPProxySpec `json:"stcp,omitempty"` + SUDP *V2SUDPProxySpec `json:"sudp,omitempty"` + XTCP *V2XTCPProxySpec `json:"xtcp,omitempty"` +} + +type V2ProxyBaseSpec struct { + Annotations map[string]string `json:"annotations,omitempty"` + Metadatas map[string]string `json:"metadatas,omitempty"` + Transport *V2ProxyTransportSpec `json:"transport,omitempty"` + LoadBalancer *V2ProxyLoadBalancerSpec `json:"loadBalancer,omitempty"` +} + +type V2ProxyTransportSpec struct { + UseEncryption bool `json:"useEncryption"` + UseCompression bool `json:"useCompression"` + BandwidthLimit string `json:"bandwidthLimit"` + BandwidthLimitMode string `json:"bandwidthLimitMode"` +} + +type V2ProxyLoadBalancerSpec struct { + Group string `json:"group"` +} + +type V2TCPProxySpec struct { + V2ProxyBaseSpec + RemotePort *int `json:"remotePort,omitempty"` +} + +type V2UDPProxySpec struct { + V2ProxyBaseSpec + RemotePort *int `json:"remotePort,omitempty"` +} + +type V2HTTPProxySpec struct { + V2ProxyBaseSpec + CustomDomains []string `json:"customDomains,omitempty"` + Subdomain string `json:"subdomain,omitempty"` + Locations []string `json:"locations,omitempty"` + HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` +} + +type V2HTTPSProxySpec struct { + V2ProxyBaseSpec + CustomDomains []string `json:"customDomains,omitempty"` + Subdomain string `json:"subdomain,omitempty"` +} + +type V2TCPMuxProxySpec struct { + V2ProxyBaseSpec + CustomDomains []string `json:"customDomains,omitempty"` + Subdomain string `json:"subdomain,omitempty"` + Multiplexer string `json:"multiplexer,omitempty"` + RouteByHTTPUser string `json:"routeByHTTPUser,omitempty"` +} + +type V2STCPProxySpec struct { + V2ProxyBaseSpec +} + +type V2SUDPProxySpec struct { + V2ProxyBaseSpec +} + +type V2XTCPProxySpec struct { + V2ProxyBaseSpec +} + type V2ProxyStatusResp struct { State string `json:"phase"` TodayTrafficIn int64 `json:"todayTrafficIn"` diff --git a/web/frps/src/api/proxy.ts b/web/frps/src/api/proxy.ts index 47f00e79..de07cac7 100644 --- a/web/frps/src/api/proxy.ts +++ b/web/frps/src/api/proxy.ts @@ -6,6 +6,9 @@ import type { ProxyListV2Params, ProxyStatsInfo, ProxyV2Info, + ProxyV2Spec, + ProxyV2SpecBlocks, + ProxyV2Type, TrafficResponse, } from '../types/proxy' @@ -39,19 +42,53 @@ export const getProxiesV2 = async (params: ProxyListV2Params = {}) => { } } -export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => ({ - name: proxy.name, - type: proxy.type, - conf: proxy.spec, - user: proxy.user, - clientID: proxy.clientID, - todayTrafficIn: proxy.status.todayTrafficIn, - todayTrafficOut: proxy.status.todayTrafficOut, - curConns: proxy.status.curConns, - lastStartTime: formatUnixSeconds(proxy.status.lastStartAt), - lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt), - status: proxy.status.phase, -}) +const getActiveProxySpec = ( + spec: ProxyV2Spec, +): ProxyV2SpecBlocks[ProxyV2Type] => { + switch (spec.type) { + case 'tcp': + return spec.tcp + case 'udp': + return spec.udp + case 'http': + return spec.http + case 'https': + return spec.https + case 'tcpmux': + return spec.tcpmux + case 'stcp': + return spec.stcp + case 'sudp': + return spec.sudp + case 'xtcp': + return spec.xtcp + default: + return assertNever(spec) + } +} + +const assertNever = (value: never): never => { + throw new Error(`Unsupported proxy spec: ${JSON.stringify(value)}`) +} + +export const toLegacyProxyStats = (proxy: ProxyV2Info): ProxyStatsInfo => { + const type = proxy.spec.type + const activeSpec = getActiveProxySpec(proxy.spec) + + return { + name: proxy.name, + type, + conf: proxy.status.phase === 'offline' ? null : activeSpec, + user: proxy.user, + clientID: proxy.clientID, + todayTrafficIn: proxy.status.todayTrafficIn, + todayTrafficOut: proxy.status.todayTrafficOut, + curConns: proxy.status.curConns, + lastStartTime: formatUnixSeconds(proxy.status.lastStartAt), + lastCloseTime: formatUnixSeconds(proxy.status.lastCloseAt), + status: proxy.status.phase, + } +} export const getProxy = (type: string, name: string) => { return http.get(`../api/proxy/${type}/${name}`) diff --git a/web/frps/src/types/proxy.ts b/web/frps/src/types/proxy.ts index 1d2a1036..27606ef5 100644 --- a/web/frps/src/types/proxy.ts +++ b/web/frps/src/types/proxy.ts @@ -28,13 +28,84 @@ export interface ProxyListV2Params { export interface ProxyV2Info { name: string - type: string user: string clientID: string - spec: any + spec: ProxyV2Spec status: ProxyV2Status } +export interface ProxyV2BaseSpec { + annotations?: Record + metadatas?: Record + transport?: { + useEncryption: boolean + useCompression: boolean + bandwidthLimit: string + bandwidthLimitMode: string + } + loadBalancer?: { + group: string + } +} + +export interface ProxyV2TCPBlock extends ProxyV2BaseSpec { + remotePort?: number +} + +export interface ProxyV2UDPBlock extends ProxyV2BaseSpec { + remotePort?: number +} + +export interface ProxyV2HTTPBlock extends ProxyV2BaseSpec { + customDomains?: string[] + subdomain?: string + locations?: string[] + hostHeaderRewrite?: string +} + +export interface ProxyV2HTTPSBlock extends ProxyV2BaseSpec { + customDomains?: string[] + subdomain?: string +} + +export interface ProxyV2TCPMuxBlock extends ProxyV2BaseSpec { + customDomains?: string[] + subdomain?: string + multiplexer?: string + routeByHTTPUser?: string +} + +export type ProxyV2STCPBlock = ProxyV2BaseSpec + +export type ProxyV2SUDPBlock = ProxyV2BaseSpec + +export type ProxyV2XTCPBlock = ProxyV2BaseSpec + +export interface ProxyV2SpecBlocks { + tcp: ProxyV2TCPBlock + udp: ProxyV2UDPBlock + http: ProxyV2HTTPBlock + https: ProxyV2HTTPSBlock + tcpmux: ProxyV2TCPMuxBlock + stcp: ProxyV2STCPBlock + sudp: ProxyV2SUDPBlock + xtcp: ProxyV2XTCPBlock +} + +export type ProxyV2Type = keyof ProxyV2SpecBlocks + +type ProxyV2SpecFor = { + type: T +} & { + [K in T]: ProxyV2SpecBlocks[K] +} & { + [K in Exclude]?: never +} + +export type ProxyV2Spec = { + [T in ProxyV2Type]: ProxyV2SpecFor +}[ProxyV2Type] + export interface ProxyV2Status { phase: 'online' | 'offline' todayTrafficIn: number