mirror of
https://github.com/fatedier/frp.git
synced 2026-07-09 08:52:32 +00:00
871 lines
31 KiB
Go
871 lines
31 KiB
Go
// 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"
|
|
"fmt"
|
|
"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"
|
|
"github.com/fatedier/frp/server/http/model"
|
|
serverproxy "github.com/fatedier/frp/server/proxy"
|
|
"github.com/fatedier/frp/server/registry"
|
|
)
|
|
|
|
type v2EnvelopeForTest[T any] struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data T `json:"data"`
|
|
}
|
|
|
|
type fakeStatsCollector struct {
|
|
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{}}
|
|
}
|
|
|
|
func (f *fakeStatsCollector) GetProxiesByType(proxyType string) []*mem.ProxyStats {
|
|
items := make([]*mem.ProxyStats, 0)
|
|
for _, ps := range f.proxies {
|
|
if ps.Type == proxyType {
|
|
items = append(items, ps)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func (f *fakeStatsCollector) GetProxiesByTypeAndName(proxyType string, proxyName string) *mem.ProxyStats {
|
|
ps := f.proxies[proxyName]
|
|
if ps != nil && ps.Type == proxyType {
|
|
return ps
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStatsCollector) GetProxyByName(proxyName string) *mem.ProxyStats {
|
|
return f.proxies[proxyName]
|
|
}
|
|
|
|
func (f *fakeStatsCollector) GetProxyTraffic(name string) *mem.ProxyTrafficInfo {
|
|
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())
|
|
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())
|
|
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)
|
|
|
|
resp := performRequest(router, "/api/v2/clients?page=1&pageSize=1")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
|
}
|
|
pageResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
|
if pageResp.Code != http.StatusOK || pageResp.Msg != "success" {
|
|
t.Fatalf("envelope mismatch: %#v", pageResp)
|
|
}
|
|
if pageResp.Data.Total != 3 || pageResp.Data.Page != 1 || pageResp.Data.PageSize != 1 || len(pageResp.Data.Items) != 1 {
|
|
t.Fatalf("page data mismatch: %#v", pageResp.Data)
|
|
}
|
|
if got := pageResp.Data.Items[0].User; got != "" {
|
|
t.Fatalf("first sorted user mismatch, want empty got %q", got)
|
|
}
|
|
|
|
resp = performRequest(router, "/api/v2/clients?user=&page=1&pageSize=50")
|
|
emptyUserResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
|
if emptyUserResp.Data.Total != 1 || emptyUserResp.Data.Items[0].User != "" {
|
|
t.Fatalf("empty user filter mismatch: %#v", emptyUserResp.Data)
|
|
}
|
|
|
|
resp = performRequest(router, "/api/v2/clients?user=alice&status=online&q=alice-host")
|
|
aliceResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
|
if aliceResp.Data.Total != 1 || aliceResp.Data.Items[0].User != "alice" {
|
|
t.Fatalf("alice filter mismatch: %#v", aliceResp.Data)
|
|
}
|
|
|
|
resp = performRequest(router, "/api/v2/clients?status=offline")
|
|
offlineResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.ClientInfoResp]]](t, resp)
|
|
if offlineResp.Data.Total != 1 || offlineResp.Data.Items[0].User != "bob" {
|
|
t.Fatalf("offline filter mismatch: %#v", offlineResp.Data)
|
|
}
|
|
}
|
|
|
|
func TestAPIV2PageParamErrorsUseEnvelope(t *testing.T) {
|
|
controller := newV2TestController(t)
|
|
router := newV2TestRouter(controller)
|
|
|
|
resp := performRequest(router, "/api/v2/clients?page=0")
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
}
|
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
|
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
|
t.Fatalf("error envelope mismatch: %#v", errResp)
|
|
}
|
|
|
|
resp = performRequest(router, "/api/v2/clients?pageSize=201")
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
}
|
|
|
|
resp = performRequest(router, fmt.Sprintf("/api/v2/clients?page=%d&pageSize=2", math.MaxInt))
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("status mismatch for overflowing page offset, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestAPIV2ClientDetailEnvelope(t *testing.T) {
|
|
controller := newV2TestController(t)
|
|
router := newV2TestRouter(controller)
|
|
|
|
resp := performRequest(router, "/api/v2/clients/alice.client-a")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
|
|
}
|
|
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())
|
|
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) {
|
|
controller := newV2TestController(t)
|
|
router := newV2TestRouter(controller)
|
|
|
|
resp := performRequest(router, "/api/v2/proxies?type=invalid")
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid proxy type status mismatch, want %d got %d", http.StatusBadRequest, resp.Code)
|
|
}
|
|
errResp := decodeResponse[httppkg.V2Response](t, resp)
|
|
if errResp.Code != http.StatusBadRequest || errResp.Data != nil {
|
|
t.Fatalf("invalid proxy type error envelope mismatch: %#v", errResp)
|
|
}
|
|
|
|
resp = performRequest(router, "/api/v2/proxies?type=tcp&user=&page=1&pageSize=50")
|
|
proxyResp := decodeResponse[v2EnvelopeForTest[model.V2PageResp[model.V2ProxyResp]]](t, resp)
|
|
if proxyResp.Data.Total != 1 {
|
|
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" {
|
|
t.Fatalf("proxy item mismatch: %#v", proxyItem)
|
|
}
|
|
|
|
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)
|
|
}
|
|
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 != 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())
|
|
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())
|
|
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
|
|
item model.V2ProxyResp
|
|
q string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "tcp remote port",
|
|
item: model.V2ProxyResp{Name: "tcp-proxy", Type: "tcp", Spec: &model.TCPOutConf{
|
|
RemotePort: 6000,
|
|
}},
|
|
q: "6000",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "udp remote port",
|
|
item: model.V2ProxyResp{Name: "udp-proxy", Type: "udp", Spec: &model.UDPOutConf{
|
|
RemotePort: 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,
|
|
}},
|
|
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"}},
|
|
}},
|
|
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"},
|
|
}},
|
|
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"},
|
|
}},
|
|
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"}},
|
|
}},
|
|
q: "mux.example.com",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "nil spec does not match spec fields",
|
|
item: model.V2ProxyResp{Name: "offline-proxy", Type: "tcp", Spec: nil},
|
|
q: "6000",
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := matchV2ProxyQuery(tt.item, tt.q); got != tt.want {
|
|
t.Fatalf("matchV2ProxyQuery() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLegacyAPIResponsesRemainBare(t *testing.T) {
|
|
controller := newV2TestController(t)
|
|
router := newV2TestRouter(controller)
|
|
|
|
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())
|
|
}
|
|
if len(clients) != 3 {
|
|
t.Fatalf("legacy clients total mismatch, want 3 got %d", len(clients))
|
|
}
|
|
|
|
resp = performRequest(router, "/api/proxy/tcp")
|
|
var proxies model.GetProxyInfoResp
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &proxies); err != nil {
|
|
t.Fatalf("legacy proxy response should be {proxies}: %v, body: %s", err, resp.Body.String())
|
|
}
|
|
if len(proxies.Proxies) != 2 {
|
|
t.Fatalf("legacy tcp proxy total mismatch, want 2 got %d", len(proxies.Proxies))
|
|
}
|
|
var envelope httppkg.V2Response
|
|
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 newV2TestController(t *testing.T) *Controller {
|
|
t.Helper()
|
|
|
|
oldStatsCollector := mem.StatsCollector
|
|
mem.StatsCollector = &fakeStatsCollector{
|
|
proxies: map[string]*mem.ProxyStats{
|
|
"tcp-empty": {
|
|
Name: "tcp-empty",
|
|
Type: "tcp",
|
|
User: "",
|
|
ClientID: "legacy-client",
|
|
TodayTrafficIn: 10,
|
|
TodayTrafficOut: 20,
|
|
CurConns: 1,
|
|
},
|
|
"tcp-alice": {
|
|
Name: "tcp-alice",
|
|
Type: "tcp",
|
|
User: "alice",
|
|
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",
|
|
Type: "udp",
|
|
User: "bob",
|
|
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
|
|
})
|
|
|
|
clientRegistry := registry.NewClientRegistry()
|
|
clientRegistry.Register("", "legacy-client", "run-empty", "empty-host", "1.0.0", "127.0.0.1", "v1")
|
|
clientRegistry.Register("alice", "client-a", "run-a", "alice-host", "1.0.0", "127.0.0.2", "v2")
|
|
clientRegistry.Register("bob", "client-b", "run-b", "bob-host", "1.0.0", "127.0.0.3", "v1")
|
|
clientRegistry.MarkOfflineByRunID("run-b")
|
|
|
|
return NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
|
|
}
|
|
|
|
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)
|
|
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)
|
|
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 {
|
|
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
|
|
}
|
|
|
|
func decodeResponse[T any](t *testing.T, resp *httptest.ResponseRecorder) T {
|
|
t.Helper()
|
|
|
|
var out T
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("unmarshal response failed: %v, body: %s", err, resp.Body.String())
|
|
}
|
|
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
|
|
}
|