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:
2026-08-10 11:55:54 +08:00
132 changed files with 11355 additions and 2223 deletions
+563 -25
View File
@@ -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
}