mirror of
https://github.com/fatedier/frp.git
synced 2026-06-30 06:11:59 -06:00
429 lines
14 KiB
Go
429 lines
14 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"
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
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 {
|
|
proxies map[string]*mem.ProxyStats
|
|
}
|
|
|
|
func (f *fakeStatsCollector) GetServer() *mem.ServerStats {
|
|
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 nil
|
|
}
|
|
|
|
func (f *fakeStatsCollector) ClearOfflineProxies() (int, int) {
|
|
return 0, len(f.proxies)
|
|
}
|
|
|
|
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")
|
|
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)
|
|
}
|
|
|
|
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 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/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)
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
"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",
|
|
},
|
|
},
|
|
}
|
|
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/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)
|
|
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).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)
|
|
return router
|
|
}
|
|
|
|
func performRequest(handler http.Handler, target string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, 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
|
|
}
|