forked from Mxmilu666/frp
Merge remote-tracking branch 'upstream/dev' into dev
# Conflicts: # .github/workflows/build-and-push-image.yml # cmd/frpc/sub/verify.go # go.mod # go.sum # pkg/util/version/version.go
This commit is contained in:
@@ -65,8 +65,12 @@ func NewController(
|
||||
|
||||
// /api/serverinfo
|
||||
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||
return c.buildServerInfoResp(), nil
|
||||
}
|
||||
|
||||
func (c *Controller) buildServerInfoResp() model.ServerInfoResp {
|
||||
serverStats := mem.StatsCollector.GetServer()
|
||||
svrResp := model.ServerInfoResp{
|
||||
return model.ServerInfoResp{
|
||||
Version: version.Full(),
|
||||
BindPort: c.serverCfg.BindPort,
|
||||
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||
@@ -87,8 +91,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||
ClientCounts: serverStats.ClientCounts,
|
||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||
}
|
||||
|
||||
return svrResp, nil
|
||||
}
|
||||
|
||||
// /api/clients
|
||||
|
||||
+279
-26
@@ -17,22 +17,31 @@ package http
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
"github.com/fatedier/frp/server/http/model"
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultV2Page = 1
|
||||
defaultV2PageSize = 50
|
||||
maxV2PageSize = 200
|
||||
|
||||
v2SystemPruneTypeOfflineProxies = "offline_proxies"
|
||||
v2ProxyTrafficDefaultDays = 7
|
||||
v2ProxyTrafficUnit = "bytes"
|
||||
v2ProxyTrafficGranularity = "day"
|
||||
)
|
||||
|
||||
var apiV2ProxyTypes = []string{
|
||||
@@ -46,6 +55,55 @@ var apiV2ProxyTypes = []string{
|
||||
string(v1.ProxyTypeSUDP),
|
||||
}
|
||||
|
||||
// /api/v2/system/info
|
||||
func (c *Controller) APIV2SystemInfo(ctx *httppkg.Context) (any, error) {
|
||||
info := c.buildServerInfoResp()
|
||||
proxyTypeCounts := info.ProxyTypeCounts
|
||||
if proxyTypeCounts == nil {
|
||||
proxyTypeCounts = map[string]int64{}
|
||||
}
|
||||
|
||||
return model.V2SystemInfoResp{
|
||||
Version: info.Version,
|
||||
Config: model.V2SystemInfoConfigResp{
|
||||
BindPort: info.BindPort,
|
||||
VhostHTTPPort: info.VhostHTTPPort,
|
||||
VhostHTTPSPort: info.VhostHTTPSPort,
|
||||
TCPMuxHTTPConnectPort: info.TCPMuxHTTPConnectPort,
|
||||
KCPBindPort: info.KCPBindPort,
|
||||
QUICBindPort: info.QUICBindPort,
|
||||
SubdomainHost: info.SubdomainHost,
|
||||
MaxPoolCount: info.MaxPoolCount,
|
||||
MaxPortsPerClient: info.MaxPortsPerClient,
|
||||
HeartbeatTimeout: info.HeartBeatTimeout,
|
||||
AllowPortsStr: info.AllowPortsStr,
|
||||
TLSForce: info.TLSForce,
|
||||
},
|
||||
Status: model.V2SystemInfoStatusResp{
|
||||
TotalTrafficIn: info.TotalTrafficIn,
|
||||
TotalTrafficOut: info.TotalTrafficOut,
|
||||
CurConns: info.CurConns,
|
||||
ClientCounts: info.ClientCounts,
|
||||
ProxyTypeCounts: proxyTypeCounts,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/system/prune
|
||||
func (c *Controller) APIV2SystemPrune(ctx *httppkg.Context) (any, error) {
|
||||
pruneType, err := parseV2SystemPruneType(ctx.Query("type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleared, total := mem.StatsCollector.PruneOfflineProxies()
|
||||
return model.V2SystemPruneResp{
|
||||
Type: pruneType,
|
||||
Cleared: cleared,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/users
|
||||
func (c *Controller) APIV2UserList(ctx *httppkg.Context) (any, error) {
|
||||
page, pageSize, err := parseV2PageParams(ctx)
|
||||
@@ -137,7 +195,26 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
|
||||
|
||||
// /api/v2/clients/{key}
|
||||
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
|
||||
return c.APIClientDetail(ctx)
|
||||
key, err := decodeV2PathParam(ctx, "key", "client key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.clientRegistry == nil {
|
||||
return nil, fmt.Errorf("client registry unavailable")
|
||||
}
|
||||
|
||||
info, ok := c.clientRegistry.GetByKey(key)
|
||||
if !ok {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
|
||||
}
|
||||
|
||||
resp := buildClientInfoResp(info)
|
||||
status := c.buildV2ClientStatus(info)
|
||||
return model.V2ClientDetailResp{
|
||||
ClientInfoResp: resp,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// /api/v2/proxies
|
||||
@@ -179,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)
|
||||
@@ -190,9 +267,9 @@ func (c *Controller) APIV2ProxyList(ctx *httppkg.Context) (any, error) {
|
||||
|
||||
// /api/v2/proxies/{name}
|
||||
func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
name := ctx.Param("name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing proxy name")
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ps := mem.StatsCollector.GetProxyByName(name)
|
||||
@@ -202,6 +279,33 @@ func (c *Controller) APIV2ProxyDetail(ctx *httppkg.Context) (any, error) {
|
||||
return c.buildV2ProxyResp(ps), nil
|
||||
}
|
||||
|
||||
// /api/v2/proxies/{name}/traffic
|
||||
func (c *Controller) APIV2ProxyTraffic(ctx *httppkg.Context) (any, error) {
|
||||
name, err := decodeV2PathParam(ctx, "name", "proxy name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
||||
if proxyTrafficInfo == nil {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||
}
|
||||
|
||||
return buildV2ProxyTrafficResp(name, proxyTrafficInfo, time.Now()), nil
|
||||
}
|
||||
|
||||
func decodeV2PathParam(ctx *httppkg.Context, key string, label string) (string, error) {
|
||||
raw := ctx.Param(key)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("missing %s", label)
|
||||
}
|
||||
decoded, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("invalid %s", label))
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func getOrCreateV2User(items map[string]*model.V2UserResp, user string) *model.V2UserResp {
|
||||
item, ok := items[user]
|
||||
if !ok {
|
||||
@@ -261,6 +365,18 @@ func parseV2ProxyTypeFilter(raw string) (string, error) {
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of tcp, udp, http, https, tcpmux, stcp, xtcp, sudp")
|
||||
}
|
||||
|
||||
func parseV2SystemPruneType(raw string) (string, error) {
|
||||
pruneType := strings.ToLower(raw)
|
||||
switch pruneType {
|
||||
case "":
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type is required")
|
||||
case v2SystemPruneTypeOfflineProxies:
|
||||
return pruneType, nil
|
||||
default:
|
||||
return "", httppkg.NewError(http.StatusBadRequest, "type must be one of offline_proxies")
|
||||
}
|
||||
}
|
||||
|
||||
func matchV2StatusFilter(online bool, filter string) bool {
|
||||
switch filter {
|
||||
case "", "all":
|
||||
@@ -320,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...)
|
||||
@@ -366,29 +492,156 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
|
||||
return items
|
||||
}
|
||||
|
||||
func buildV2ProxyTrafficResp(name string, traffic *mem.ProxyTrafficInfo, now time.Time) model.V2ProxyTrafficResp {
|
||||
history := make([]model.V2ProxyTrafficPointResp, 0, v2ProxyTrafficDefaultDays)
|
||||
for age := v2ProxyTrafficDefaultDays - 1; age >= 0; age-- {
|
||||
history = append(history, model.V2ProxyTrafficPointResp{
|
||||
Date: now.AddDate(0, 0, -age).Format(time.DateOnly),
|
||||
TrafficIn: v2TrafficValueAt(traffic.TrafficIn, age),
|
||||
TrafficOut: v2TrafficValueAt(traffic.TrafficOut, age),
|
||||
})
|
||||
}
|
||||
|
||||
return model.V2ProxyTrafficResp{
|
||||
Name: name,
|
||||
Unit: v2ProxyTrafficUnit,
|
||||
Granularity: v2ProxyTrafficGranularity,
|
||||
History: history,
|
||||
}
|
||||
}
|
||||
|
||||
func v2TrafficValueAt(values []int64, todayFirstIndex int) int64 {
|
||||
if todayFirstIndex >= len(values) {
|
||||
return 0
|
||||
}
|
||||
return values[todayFirstIndex]
|
||||
}
|
||||
|
||||
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
|
||||
status := model.V2ClientStatusResp{State: "offline"}
|
||||
if info.Online {
|
||||
status.State = "online"
|
||||
}
|
||||
|
||||
user := info.User
|
||||
clientID := info.ClientID()
|
||||
for _, ps := range c.listV2ProxyStats("") {
|
||||
if ps.User != user || ps.ClientID != clientID {
|
||||
continue
|
||||
}
|
||||
status.CurConns += ps.CurConns
|
||||
status.ProxyCount++
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
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,
|
||||
TodayTrafficOut: ps.TodayTrafficOut,
|
||||
CurConns: ps.CurConns,
|
||||
LastStartTime: ps.LastStartTime,
|
||||
LastCloseTime: ps.LastCloseTime,
|
||||
LastStartAt: ps.LastStartAt,
|
||||
LastCloseAt: ps.LastCloseAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -20,10 +20,13 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/types"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
@@ -32,6 +35,10 @@ import (
|
||||
"github.com/fatedier/frp/server/registry"
|
||||
)
|
||||
|
||||
type stubControlManager struct{}
|
||||
|
||||
func (stubControlManager) CloseAllProxyByName(string) error { return nil }
|
||||
|
||||
type v2EnvelopeForTest[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
@@ -39,10 +46,16 @@ type v2EnvelopeForTest[T any] struct {
|
||||
}
|
||||
|
||||
type fakeStatsCollector struct {
|
||||
proxies map[string]*mem.ProxyStats
|
||||
server *mem.ServerStats
|
||||
proxies map[string]*mem.ProxyStats
|
||||
traffic map[string]*mem.ProxyTrafficInfo
|
||||
pruneable map[string]bool
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
||||
if f.server != nil {
|
||||
return f.server
|
||||
}
|
||||
return &mem.ServerStats{ProxyTypeCounts: map[string]int64{}}
|
||||
}
|
||||
|
||||
@@ -69,13 +82,210 @@ func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
||||
return nil
|
||||
return f.traffic[name]
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
||||
return 0, len(f.proxies)
|
||||
}
|
||||
|
||||
func (f *fakeStatsCollector) PruneOfflineProxies() (int, int) {
|
||||
total := len(f.proxies)
|
||||
cleared := 0
|
||||
for name := range f.pruneable {
|
||||
if _, ok := f.proxies[name]; ok {
|
||||
delete(f.proxies, name)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
f.pruneable = map[string]bool{}
|
||||
return cleared, total
|
||||
}
|
||||
|
||||
func TestAPIV2SystemInfoEnvelope(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
server: &mem.ServerStats{
|
||||
TotalTrafficIn: 1024,
|
||||
TotalTrafficOut: 2048,
|
||||
CurConns: 3,
|
||||
ClientCounts: 4,
|
||||
ProxyTypeCounts: map[string]int64{
|
||||
"tcp": 2,
|
||||
"http": 1,
|
||||
},
|
||||
},
|
||||
proxies: map[string]*mem.ProxyStats{},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{
|
||||
BindPort: 7000,
|
||||
VhostHTTPPort: 8080,
|
||||
VhostHTTPSPort: 8443,
|
||||
TCPMuxHTTPConnectPort: 9000,
|
||||
KCPBindPort: 7001,
|
||||
QUICBindPort: 7002,
|
||||
SubDomainHost: "example.com",
|
||||
MaxPortsPerClient: 8,
|
||||
AllowPorts: []types.PortsRange{
|
||||
{Start: 1000, End: 1002},
|
||||
{Single: 2000},
|
||||
},
|
||||
Transport: v1.ServerTransportConfig{
|
||||
MaxPoolCount: 5,
|
||||
HeartbeatTimeout: 90,
|
||||
TLS: v1.TLSServerConfig{
|
||||
Force: true,
|
||||
},
|
||||
},
|
||||
}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/system/info")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "config", "status", "version")
|
||||
assertRawJSONKeysFromMessage(t, rawResp.Data["config"],
|
||||
"allowPortsStr",
|
||||
"bindPort",
|
||||
"heartbeatTimeout",
|
||||
"kcpBindPort",
|
||||
"maxPoolCount",
|
||||
"maxPortsPerClient",
|
||||
"quicBindPort",
|
||||
"subdomainHost",
|
||||
"tcpmuxHTTPConnectPort",
|
||||
"tlsForce",
|
||||
"vhostHTTPPort",
|
||||
"vhostHTTPSPort",
|
||||
)
|
||||
assertRawJSONKeysFromMessage(t, rawResp.Data["status"],
|
||||
"clientCounts",
|
||||
"curConns",
|
||||
"proxyTypeCount",
|
||||
"totalTrafficIn",
|
||||
"totalTrafficOut",
|
||||
)
|
||||
|
||||
systemResp := decodeResponse[v2EnvelopeForTest[model.V2SystemInfoResp]](t, resp)
|
||||
if systemResp.Data.Version == "" {
|
||||
t.Fatal("version should be set at top level")
|
||||
}
|
||||
if systemResp.Data.Config.BindPort != 7000 ||
|
||||
systemResp.Data.Config.VhostHTTPPort != 8080 ||
|
||||
systemResp.Data.Config.VhostHTTPSPort != 8443 ||
|
||||
systemResp.Data.Config.TCPMuxHTTPConnectPort != 9000 ||
|
||||
systemResp.Data.Config.KCPBindPort != 7001 ||
|
||||
systemResp.Data.Config.QUICBindPort != 7002 ||
|
||||
systemResp.Data.Config.SubdomainHost != "example.com" ||
|
||||
systemResp.Data.Config.MaxPoolCount != 5 ||
|
||||
systemResp.Data.Config.MaxPortsPerClient != 8 ||
|
||||
systemResp.Data.Config.HeartbeatTimeout != 90 ||
|
||||
systemResp.Data.Config.AllowPortsStr != "1000-1002,2000" ||
|
||||
!systemResp.Data.Config.TLSForce {
|
||||
t.Fatalf("config mismatch: %#v", systemResp.Data.Config)
|
||||
}
|
||||
if systemResp.Data.Status.TotalTrafficIn != 1024 ||
|
||||
systemResp.Data.Status.TotalTrafficOut != 2048 ||
|
||||
systemResp.Data.Status.CurConns != 3 ||
|
||||
systemResp.Data.Status.ClientCounts != 4 ||
|
||||
systemResp.Data.Status.ProxyTypeCounts["tcp"] != 2 ||
|
||||
systemResp.Data.Status.ProxyTypeCounts["http"] != 1 {
|
||||
t.Fatalf("status mismatch: %#v", systemResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2SystemPruneOfflineProxies(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
collector := &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-offline": {Name: "tcp-offline", Type: "tcp"},
|
||||
"http-offline": {Name: "http-offline", Type: "http"},
|
||||
"udp-offline": {Name: "udp-offline", Type: "udp"},
|
||||
"tcp-online": {Name: "tcp-online", Type: "tcp"},
|
||||
"http-online": {Name: "http-online", Type: "http"},
|
||||
"udp-online": {Name: "udp-online", Type: "udp"},
|
||||
"stcp-restarted": {Name: "stcp-restarted", Type: "stcp"},
|
||||
"xtcp-restarted": {Name: "xtcp-restarted", Type: "xtcp"},
|
||||
"sudp-same-time": {Name: "sudp-same-time", Type: "sudp"},
|
||||
"tcpmux-running": {Name: "tcpmux-running", Type: "tcpmux"},
|
||||
},
|
||||
pruneable: map[string]bool{
|
||||
"tcp-offline": true,
|
||||
"http-offline": true,
|
||||
"udp-offline": true,
|
||||
},
|
||||
}
|
||||
mem.StatsCollector = collector
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "cleared", "total", "type")
|
||||
pruneResp := decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||
if pruneResp.Data.Type != "offline_proxies" || pruneResp.Data.Cleared != 3 || pruneResp.Data.Total != 10 {
|
||||
t.Fatalf("prune response mismatch: %#v", pruneResp.Data)
|
||||
}
|
||||
if _, ok := collector.proxies["tcp-offline"]; ok {
|
||||
t.Fatal("pruned proxy statistics should be removed")
|
||||
}
|
||||
if _, ok := collector.proxies["tcp-online"]; !ok {
|
||||
t.Fatal("online proxy statistics should remain")
|
||||
}
|
||||
|
||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=offline_proxies")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("second prune status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
pruneResp = decodeResponse[v2EnvelopeForTest[model.V2SystemPruneResp]](t, resp)
|
||||
if pruneResp.Data.Cleared != 0 || pruneResp.Data.Total != 7 {
|
||||
t.Fatalf("second prune response mismatch: %#v", pruneResp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2SystemPruneTypeErrorsUseEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type is required" || errResp.Data != nil {
|
||||
t.Fatalf("missing type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
|
||||
resp = performRequestWithMethod(router, http.MethodPost, "/api/v2/system/prune?type=clients")
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
errResp = decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "type must be one of offline_proxies" || errResp.Data != nil {
|
||||
t.Fatalf("invalid type error envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientListEnvelopePaginationAndFilters(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
@@ -146,10 +356,49 @@ func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp)
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
|
||||
t.Fatalf("client detail mismatch: %#v", detailResp.Data)
|
||||
}
|
||||
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
|
||||
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"tcp-url": {
|
||||
Name: "tcp-url",
|
||||
Type: "tcp",
|
||||
User: "url",
|
||||
ClientID: "client/a?b#c",
|
||||
CurConns: 7,
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
clientRegistry := registry.NewClientRegistry()
|
||||
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
|
||||
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
encodedKey := url.PathEscape("url.client/a?b#c")
|
||||
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
|
||||
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
|
||||
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
|
||||
}
|
||||
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
|
||||
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
|
||||
@@ -171,28 +420,194 @@ 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)
|
||||
assertRawJSONKeysFromMessage(t, rawProxyDetailResp.Data["status"],
|
||||
"curConns",
|
||||
"lastCloseAt",
|
||||
"lastStartAt",
|
||||
"phase",
|
||||
"todayTrafficIn",
|
||||
"todayTrafficOut",
|
||||
)
|
||||
proxyDetailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||
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)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/users?page=1&pageSize=50")
|
||||
userResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2UserResp]]](t, resp)
|
||||
if userResp.Data.Total != 3 {
|
||||
t.Fatalf("user total mismatch: %#v", userResp.Data)
|
||||
}
|
||||
expectedProxyCounts := map[string]int{
|
||||
"": 1,
|
||||
"alice": 2,
|
||||
"bob": 1,
|
||||
}
|
||||
for _, item := range userResp.Data.Items {
|
||||
if item.ClientCount != 1 || item.ProxyCount != 1 {
|
||||
if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
|
||||
t.Fatalf("user counts mismatch: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficEnvelopeSchemaAndHistory(t *testing.T) {
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
"ssh": {Name: "ssh", Type: "tcp"},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
"ssh": {
|
||||
Name: "ssh",
|
||||
TrafficIn: []int64{70, 60, 50, 40, 30, 20, 10},
|
||||
TrafficOut: []int64{700, 600, 500, 400, 300, 200, 100},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/ssh/traffic")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
rawResp := decodeResponse[v2EnvelopeForTest[map[string]json.RawMessage]](t, resp)
|
||||
if rawResp.Code != http.StatusOK || rawResp.Msg != "success" {
|
||||
t.Fatalf("envelope mismatch: %#v", rawResp)
|
||||
}
|
||||
assertRawJSONKeys(t, rawResp.Data, "granularity", "history", "name", "unit")
|
||||
|
||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||
if trafficResp.Data.Name != "ssh" || trafficResp.Data.Unit != "bytes" || trafficResp.Data.Granularity != "day" {
|
||||
t.Fatalf("traffic metadata mismatch: %#v", trafficResp.Data)
|
||||
}
|
||||
if len(trafficResp.Data.History) != 7 {
|
||||
t.Fatalf("history length mismatch, want 7 got %d: %#v", len(trafficResp.Data.History), trafficResp.Data.History)
|
||||
}
|
||||
|
||||
wantIn := []int64{10, 20, 30, 40, 50, 60, 70}
|
||||
wantOut := []int64{100, 200, 300, 400, 500, 600, 700}
|
||||
var prevDate time.Time
|
||||
for i, point := range trafficResp.Data.History {
|
||||
assertRawJSONKeysFromMessage(t, mustMarshalJSON(t, point), "date", "trafficIn", "trafficOut")
|
||||
if point.TrafficIn != wantIn[i] || point.TrafficOut != wantOut[i] {
|
||||
t.Fatalf("history[%d] traffic mismatch: %#v", i, point)
|
||||
}
|
||||
parsedDate, err := time.Parse(time.DateOnly, point.Date)
|
||||
if err != nil {
|
||||
t.Fatalf("history[%d] date should be yyyy-mm-dd, got %q: %v", i, point.Date, err)
|
||||
}
|
||||
if i > 0 && !parsedDate.Equal(prevDate.AddDate(0, 0, 1)) {
|
||||
t.Fatalf("history dates should be oldest to newest, got %s after %s", point.Date, prevDate.Format(time.DateOnly))
|
||||
}
|
||||
prevDate = parsedDate
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficNotFoundEnvelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/missing/traffic")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusNotFound, resp.Code, resp.Body.String())
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusNotFound || errResp.Msg != "no proxy info found" || errResp.Data != nil {
|
||||
t.Fatalf("not found envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyDetailAndTrafficEncodedName(t *testing.T) {
|
||||
name := "folder/ssh?x#y"
|
||||
oldStatsCollector := mem.StatsCollector
|
||||
mem.StatsCollector = &fakeStatsCollector{
|
||||
proxies: map[string]*mem.ProxyStats{
|
||||
name: {Name: name, Type: "tcp", User: "encoded"},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
name: {
|
||||
Name: name,
|
||||
TrafficIn: []int64{1},
|
||||
TrafficOut: []int64{2},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
})
|
||||
|
||||
controller := NewController(&v1.ServerConfig{}, registry.NewClientRegistry(), serverproxy.NewManager(), stubControlManager{})
|
||||
router := newV2TestRouter(controller)
|
||||
encodedName := url.PathEscape(name)
|
||||
|
||||
resp := performRequest(router, "/api/v2/proxies/"+encodedName)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded proxy detail status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
detailResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyResp]](t, resp)
|
||||
if detailResp.Data.Name != name || detailResp.Data.User != "encoded" {
|
||||
t.Fatalf("encoded proxy detail mismatch: %#v", detailResp.Data)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/v2/proxies/"+encodedName+"/traffic")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("encoded traffic status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
|
||||
}
|
||||
trafficResp := decodeResponse[v2EnvelopeForTest[model.V2ProxyTrafficResp]](t, resp)
|
||||
if trafficResp.Data.Name != name {
|
||||
t.Fatalf("encoded traffic name mismatch: %#v", trafficResp.Data)
|
||||
}
|
||||
if got := trafficResp.Data.History[len(trafficResp.Data.History)-1]; got.TrafficIn != 1 || got.TrafficOut != 2 {
|
||||
t.Fatalf("encoded traffic latest point mismatch: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV2ProxyTrafficInvalidEncodedNameUses400Envelope(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
handler := httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/proxies/%25ZZ/traffic", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "%ZZ"})
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status mismatch, want %d got %d, body: %s", http.StatusBadRequest, resp.Code, resp.Body.String())
|
||||
}
|
||||
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
||||
if errResp.Code != http.StatusBadRequest || errResp.Msg != "invalid proxy name" || errResp.Data != nil {
|
||||
t.Fatalf("invalid encoded name envelope mismatch: %#v", errResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -202,66 +617,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 {
|
||||
@@ -277,7 +711,26 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
controller := newV2TestController(t)
|
||||
router := newV2TestRouter(controller)
|
||||
|
||||
resp := performRequest(router, "/api/clients")
|
||||
resp := performRequest(router, "/api/serverinfo")
|
||||
var serverInfo model.ServerInfoResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfo); err != nil {
|
||||
t.Fatalf("legacy serverinfo should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if serverInfo.Version == "" {
|
||||
t.Fatal("legacy serverinfo version should be set")
|
||||
}
|
||||
var serverInfoRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &serverInfoRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy serverinfo object failed: %v", err)
|
||||
}
|
||||
if _, ok := serverInfoRaw["data"]; ok {
|
||||
t.Fatalf("legacy serverinfo should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
if _, ok := serverInfoRaw["config"]; ok {
|
||||
t.Fatalf("legacy serverinfo should stay flat, got config in: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/clients")
|
||||
var clients []model.ClientInfoResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &clients); err != nil {
|
||||
t.Fatalf("legacy clients should be a bare array: %v, body: %s", err, resp.Body.String())
|
||||
@@ -298,6 +751,28 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err == nil && envelope.Code != 0 {
|
||||
t.Fatalf("legacy proxy response should not use v2 envelope: %#v", envelope)
|
||||
}
|
||||
|
||||
resp = performRequest(router, "/api/traffic/tcp-alice")
|
||||
var traffic model.GetProxyTrafficResp
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &traffic); err != nil {
|
||||
t.Fatalf("legacy traffic should be a bare object: %v, body: %s", err, resp.Body.String())
|
||||
}
|
||||
if traffic.Name != "tcp-alice" ||
|
||||
len(traffic.TrafficIn) != 2 || traffic.TrafficIn[0] != 7 || traffic.TrafficIn[1] != 6 ||
|
||||
len(traffic.TrafficOut) != 2 || traffic.TrafficOut[0] != 70 || traffic.TrafficOut[1] != 60 {
|
||||
t.Fatalf("legacy traffic should preserve today-first arrays, got: %#v", traffic)
|
||||
}
|
||||
var trafficRaw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &trafficRaw); err != nil {
|
||||
t.Fatalf("unmarshal legacy traffic object failed: %v", err)
|
||||
}
|
||||
if _, ok := trafficRaw["data"]; ok {
|
||||
t.Fatalf("legacy traffic should not use v2 envelope: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func v2TestIntPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newV2TestController(t *testing.T) *Controller {
|
||||
@@ -322,6 +797,18 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
ClientID: "client-a",
|
||||
TodayTrafficIn: 30,
|
||||
TodayTrafficOut: 40,
|
||||
CurConns: 2,
|
||||
LastStartTime: "07-08 12:30:00",
|
||||
LastCloseTime: "07-08 12:31:40",
|
||||
LastStartAt: 1783504200,
|
||||
LastCloseAt: 1783504300,
|
||||
},
|
||||
"http-alice": {
|
||||
Name: "http-alice",
|
||||
Type: "http",
|
||||
User: "alice",
|
||||
ClientID: "client-a",
|
||||
CurConns: 3,
|
||||
},
|
||||
"udp-bob": {
|
||||
Name: "udp-bob",
|
||||
@@ -330,6 +817,13 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
ClientID: "client-b",
|
||||
},
|
||||
},
|
||||
traffic: map[string]*mem.ProxyTrafficInfo{
|
||||
"tcp-alice": {
|
||||
Name: "tcp-alice",
|
||||
TrafficIn: []int64{7, 6},
|
||||
TrafficOut: []int64{70, 60},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
mem.StatsCollector = oldStatsCollector
|
||||
@@ -347,17 +841,28 @@ func newV2TestController(t *testing.T) *Controller {
|
||||
func newV2TestRouter(controller *Controller) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemInfo)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2SystemPrune)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||
encodedPathRouter := router.NewRoute().Subrouter()
|
||||
encodedPathRouter.UseEncodedPath()
|
||||
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyTraffic)).Methods(http.MethodGet)
|
||||
encodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(controller.APIServerInfo)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyByType)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(controller.APIProxyTraffic)).Methods(http.MethodGet)
|
||||
return router
|
||||
}
|
||||
|
||||
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
return performRequestWithMethod(handler, http.MethodGet, target)
|
||||
}
|
||||
|
||||
func performRequestWithMethod(handler http.Handler, method, target string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
return resp
|
||||
@@ -372,3 +877,36 @@ func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertRawJSONKeys(t *testing.T, raw map[string]json.RawMessage, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
if len(raw) != len(want) {
|
||||
t.Fatalf("json keys mismatch, want %v got %v", want, raw)
|
||||
}
|
||||
for _, key := range want {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Fatalf("json key %q missing from %v", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRawJSONKeysFromMessage(t *testing.T, raw json.RawMessage, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
var out map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal raw json object failed: %v, body: %s", err, string(raw))
|
||||
}
|
||||
assertRawJSONKeys(t, out, want...)
|
||||
}
|
||||
|
||||
func mustMarshalJSON(t *testing.T, value any) json.RawMessage {
|
||||
t.Helper()
|
||||
|
||||
out, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json failed: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+137
-4
@@ -21,26 +21,159 @@ type V2PageResp[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
}
|
||||
|
||||
type V2SystemInfoResp struct {
|
||||
Version string `json:"version"`
|
||||
Config V2SystemInfoConfigResp `json:"config"`
|
||||
Status V2SystemInfoStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2SystemInfoConfigResp struct {
|
||||
BindPort int `json:"bindPort"`
|
||||
VhostHTTPPort int `json:"vhostHTTPPort"`
|
||||
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
||||
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
||||
KCPBindPort int `json:"kcpBindPort"`
|
||||
QUICBindPort int `json:"quicBindPort"`
|
||||
SubdomainHost string `json:"subdomainHost"`
|
||||
MaxPoolCount int64 `json:"maxPoolCount"`
|
||||
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
||||
HeartbeatTimeout int64 `json:"heartbeatTimeout"`
|
||||
AllowPortsStr string `json:"allowPortsStr"`
|
||||
TLSForce bool `json:"tlsForce"`
|
||||
}
|
||||
|
||||
type V2SystemInfoStatusResp struct {
|
||||
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
||||
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ClientCounts int64 `json:"clientCounts"`
|
||||
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
||||
}
|
||||
|
||||
type V2SystemPruneResp struct {
|
||||
Type string `json:"type"`
|
||||
Cleared int `json:"cleared"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type V2UserResp struct {
|
||||
User string `json:"user"`
|
||||
ClientCount int `json:"clientCount"`
|
||||
ProxyCount int `json:"proxyCount"`
|
||||
}
|
||||
|
||||
type V2ClientDetailResp struct {
|
||||
ClientInfoResp
|
||||
Status V2ClientStatusResp `json:"status"`
|
||||
}
|
||||
|
||||
type V2ClientStatusResp struct {
|
||||
State string `json:"phase"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
ProxyCount int64 `json:"proxyCount"`
|
||||
}
|
||||
|
||||
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"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
LastStartTime string `json:"lastStartTime"`
|
||||
LastCloseTime string `json:"lastCloseTime"`
|
||||
LastStartAt int64 `json:"lastStartAt,omitempty"`
|
||||
LastCloseAt int64 `json:"lastCloseAt,omitempty"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficResp struct {
|
||||
Name string `json:"name"`
|
||||
Unit string `json:"unit"`
|
||||
Granularity string `json:"granularity"`
|
||||
History []V2ProxyTrafficPointResp `json:"history"`
|
||||
}
|
||||
|
||||
type V2ProxyTrafficPointResp struct {
|
||||
Date string `json:"date"`
|
||||
TrafficIn int64 `json:"trafficIn"`
|
||||
TrafficOut int64 `json:"trafficOut"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user