mirror of
https://github.com/fatedier/frp.git
synced 2026-03-08 02:49:10 +08:00
532 lines
15 KiB
Go
532 lines
15 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
"github.com/fatedier/frp/client/configmgmt"
|
|
"github.com/fatedier/frp/client/http/model"
|
|
"github.com/fatedier/frp/client/proxy"
|
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
)
|
|
|
|
type fakeConfigManager struct {
|
|
reloadFromFileFn func(strict bool) error
|
|
readConfigFileFn func() (string, error)
|
|
writeConfigFileFn func(content []byte) error
|
|
getProxyStatusFn func() []*proxy.WorkingStatus
|
|
isStoreProxyEnabledFn func(name string) bool
|
|
storeEnabledFn func() bool
|
|
|
|
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
|
|
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
|
|
createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
|
updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
|
deleteStoreProxyFn func(name string) error
|
|
listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error)
|
|
getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error)
|
|
createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
|
|
updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
|
|
deleteStoreVisitFn func(name string) error
|
|
gracefulCloseFn func(d time.Duration)
|
|
}
|
|
|
|
func (m *fakeConfigManager) ReloadFromFile(strict bool) error {
|
|
if m.reloadFromFileFn != nil {
|
|
return m.reloadFromFileFn(strict)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) ReadConfigFile() (string, error) {
|
|
if m.readConfigFileFn != nil {
|
|
return m.readConfigFileFn()
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) WriteConfigFile(content []byte) error {
|
|
if m.writeConfigFileFn != nil {
|
|
return m.writeConfigFileFn(content)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
|
|
if m.getProxyStatusFn != nil {
|
|
return m.getProxyStatusFn()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) IsStoreProxyEnabled(name string) bool {
|
|
if m.isStoreProxyEnabledFn != nil {
|
|
return m.isStoreProxyEnabledFn(name)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *fakeConfigManager) StoreEnabled() bool {
|
|
if m.storeEnabledFn != nil {
|
|
return m.storeEnabledFn()
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
|
|
if m.listStoreProxiesFn != nil {
|
|
return m.listStoreProxiesFn()
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {
|
|
if m.getStoreProxyFn != nil {
|
|
return m.getStoreProxyFn(name)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
|
if m.createStoreProxyFn != nil {
|
|
return m.createStoreProxyFn(cfg)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
|
if m.updateStoreProxyFn != nil {
|
|
return m.updateStoreProxyFn(name, cfg)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) DeleteStoreProxy(name string) error {
|
|
if m.deleteStoreProxyFn != nil {
|
|
return m.deleteStoreProxyFn(name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {
|
|
if m.listStoreVisitorsFn != nil {
|
|
return m.listStoreVisitorsFn()
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {
|
|
if m.getStoreVisitorFn != nil {
|
|
return m.getStoreVisitorFn(name)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
|
|
if m.createStoreVisitFn != nil {
|
|
return m.createStoreVisitFn(cfg)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
|
|
if m.updateStoreVisitFn != nil {
|
|
return m.updateStoreVisitFn(name, cfg)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) DeleteStoreVisitor(name string) error {
|
|
if m.deleteStoreVisitFn != nil {
|
|
return m.deleteStoreVisitFn(name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *fakeConfigManager) GracefulClose(d time.Duration) {
|
|
if m.gracefulCloseFn != nil {
|
|
m.gracefulCloseFn(d)
|
|
}
|
|
}
|
|
|
|
func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
|
|
return &v1.TCPProxyConfig{
|
|
ProxyBaseConfig: v1.ProxyBaseConfig{
|
|
Name: name,
|
|
Type: "tcp",
|
|
ProxyBackend: v1.ProxyBackend{
|
|
LocalPort: 10080,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
|
|
status := &proxy.WorkingStatus{
|
|
Name: "shared-proxy",
|
|
Type: "tcp",
|
|
Phase: proxy.ProxyPhaseRunning,
|
|
RemoteAddr: ":8080",
|
|
Cfg: newRawTCPProxyConfig("shared-proxy"),
|
|
}
|
|
|
|
controller := &Controller{
|
|
serverAddr: "127.0.0.1",
|
|
manager: &fakeConfigManager{
|
|
isStoreProxyEnabledFn: func(name string) bool {
|
|
return name == "shared-proxy"
|
|
},
|
|
},
|
|
}
|
|
|
|
resp := controller.buildProxyStatusResp(status)
|
|
if resp.Source != "store" {
|
|
t.Fatalf("unexpected source: %q", resp.Source)
|
|
}
|
|
if resp.RemoteAddr != "127.0.0.1:8080" {
|
|
t.Fatalf("unexpected remote addr: %q", resp.RemoteAddr)
|
|
}
|
|
}
|
|
|
|
func TestReloadErrorMapping(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expectedCode int
|
|
}{
|
|
{name: "invalid arg", err: fmtError(configmgmt.ErrInvalidArgument, "bad cfg"), expectedCode: http.StatusBadRequest},
|
|
{name: "apply fail", err: fmtError(configmgmt.ErrApplyConfig, "reload failed"), expectedCode: http.StatusInternalServerError},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{reloadFromFileFn: func(bool) error { return tc.err }},
|
|
}
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/reload", nil))
|
|
_, err := controller.Reload(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
assertHTTPCode(t, err, tc.expectedCode)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStoreProxyErrorMapping(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expectedCode int
|
|
}{
|
|
{name: "not found", err: fmtError(configmgmt.ErrNotFound, "not found"), expectedCode: http.StatusNotFound},
|
|
{name: "conflict", err: fmtError(configmgmt.ErrConflict, "exists"), expectedCode: http.StatusConflict},
|
|
{name: "internal", err: errors.New("persist failed"), expectedCode: http.StatusInternalServerError},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`)
|
|
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body))
|
|
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{
|
|
updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
|
return nil, tc.err
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := controller.UpdateStoreProxy(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
assertHTTPCode(t, err, tc.expectedCode)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStoreVisitorErrorMapping(t *testing.T) {
|
|
body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`)
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body))
|
|
req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"})
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{
|
|
deleteStoreVisitFn: func(string) error {
|
|
return fmtError(configmgmt.ErrStoreDisabled, "disabled")
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := controller.DeleteStoreVisitor(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
assertHTTPCode(t, err, http.StatusNotFound)
|
|
}
|
|
|
|
func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) {
|
|
var gotName string
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{
|
|
createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
|
gotName = cfg.GetBaseConfig().Name
|
|
return cfg, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
body := []byte(`{"name":"raw-proxy","type":"tcp","unexpected":"value","tcp":{"localPort":10080,"unknownInBlock":"value"}}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
resp, err := controller.CreateStoreProxy(ctx)
|
|
if err != nil {
|
|
t.Fatalf("create store proxy: %v", err)
|
|
}
|
|
if gotName != "raw-proxy" {
|
|
t.Fatalf("unexpected proxy name: %q", gotName)
|
|
}
|
|
|
|
payload, ok := resp.(model.ProxyDefinition)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type: %T", resp)
|
|
}
|
|
if payload.Type != "tcp" || payload.TCP == nil {
|
|
t.Fatalf("unexpected payload: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) {
|
|
var gotName string
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{
|
|
createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
|
|
gotName = cfg.GetBaseConfig().Name
|
|
return cfg, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
body := []byte(`{
|
|
"name":"raw-visitor","type":"xtcp","unexpected":"value",
|
|
"xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret","unknownInBlock":"value"}
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
resp, err := controller.CreateStoreVisitor(ctx)
|
|
if err != nil {
|
|
t.Fatalf("create store visitor: %v", err)
|
|
}
|
|
if gotName != "raw-visitor" {
|
|
t.Fatalf("unexpected visitor name: %q", gotName)
|
|
}
|
|
|
|
payload, ok := resp.(model.VisitorDefinition)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type: %T", resp)
|
|
}
|
|
if payload.Type != "xtcp" || payload.XTCP == nil {
|
|
t.Fatalf("unexpected payload: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) {
|
|
var gotPluginType string
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{
|
|
createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
|
gotPluginType = cfg.GetBaseConfig().Plugin.Type
|
|
return cfg, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
body := []byte(`{"name":"plugin-proxy","type":"tcp","tcp":{"plugin":{"type":"http2https","localAddr":"127.0.0.1:8080","unknownInPlugin":"value"}}}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
resp, err := controller.CreateStoreProxy(ctx)
|
|
if err != nil {
|
|
t.Fatalf("create store proxy: %v", err)
|
|
}
|
|
if gotPluginType != "http2https" {
|
|
t.Fatalf("unexpected plugin type: %q", gotPluginType)
|
|
}
|
|
payload, ok := resp.(model.ProxyDefinition)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type: %T", resp)
|
|
}
|
|
if payload.TCP == nil {
|
|
t.Fatalf("unexpected response payload: %#v", payload)
|
|
}
|
|
pluginType := payload.TCP.Plugin.Type
|
|
|
|
if pluginType != "http2https" {
|
|
t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
|
|
}
|
|
}
|
|
|
|
func TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) {
|
|
var gotPluginType string
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{
|
|
createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
|
|
gotPluginType = cfg.GetBaseConfig().Plugin.Type
|
|
return cfg, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
body := []byte(`{
|
|
"name":"plugin-visitor","type":"stcp",
|
|
"stcp":{"serverName":"server","bindPort":10081,"plugin":{"type":"virtual_net","destinationIP":"10.0.0.1","unknownInPlugin":"value"}}
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
resp, err := controller.CreateStoreVisitor(ctx)
|
|
if err != nil {
|
|
t.Fatalf("create store visitor: %v", err)
|
|
}
|
|
if gotPluginType != "virtual_net" {
|
|
t.Fatalf("unexpected plugin type: %q", gotPluginType)
|
|
}
|
|
payload, ok := resp.(model.VisitorDefinition)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type: %T", resp)
|
|
}
|
|
if payload.STCP == nil {
|
|
t.Fatalf("unexpected response payload: %#v", payload)
|
|
}
|
|
pluginType := payload.STCP.Plugin.Type
|
|
|
|
if pluginType != "virtual_net" {
|
|
t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
|
|
}
|
|
}
|
|
|
|
func TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) {
|
|
controller := &Controller{manager: &fakeConfigManager{}}
|
|
body := []byte(`{"name":"p1","type":"tcp","udp":{"localPort":10080}}`)
|
|
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
|
|
req = mux.SetURLVars(req, map[string]string{"name": "p1"})
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
_, err := controller.UpdateStoreProxy(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
assertHTTPCode(t, err, http.StatusBadRequest)
|
|
}
|
|
|
|
func TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) {
|
|
controller := &Controller{manager: &fakeConfigManager{}}
|
|
body := []byte(`{"name":"p2","type":"tcp","tcp":{"localPort":10080}}`)
|
|
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
|
|
req = mux.SetURLVars(req, map[string]string{"name": "p1"})
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
_, err := controller.UpdateStoreProxy(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
assertHTTPCode(t, err, http.StatusBadRequest)
|
|
}
|
|
|
|
func TestListStoreProxiesReturnsSortedPayload(t *testing.T) {
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{
|
|
listStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) {
|
|
b := newRawTCPProxyConfig("b")
|
|
a := newRawTCPProxyConfig("a")
|
|
return []v1.ProxyConfigurer{b, a}, nil
|
|
},
|
|
},
|
|
}
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/store/proxies", nil))
|
|
|
|
resp, err := controller.ListStoreProxies(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list store proxies: %v", err)
|
|
}
|
|
out, ok := resp.(model.ProxyListResp)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type: %T", resp)
|
|
}
|
|
if len(out.Proxies) != 2 {
|
|
t.Fatalf("unexpected proxy count: %d", len(out.Proxies))
|
|
}
|
|
if out.Proxies[0].Name != "a" || out.Proxies[1].Name != "b" {
|
|
t.Fatalf("proxies are not sorted by name: %#v", out.Proxies)
|
|
}
|
|
}
|
|
|
|
func fmtError(sentinel error, msg string) error {
|
|
return fmt.Errorf("%w: %s", sentinel, msg)
|
|
}
|
|
|
|
func assertHTTPCode(t *testing.T, err error, expected int) {
|
|
t.Helper()
|
|
var httpErr *httppkg.Error
|
|
if !errors.As(err, &httpErr) {
|
|
t.Fatalf("unexpected error type: %T", err)
|
|
}
|
|
if httpErr.Code != expected {
|
|
t.Fatalf("unexpected status code: got %d, want %d", httpErr.Code, expected)
|
|
}
|
|
}
|
|
|
|
func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
|
|
controller := &Controller{
|
|
manager: &fakeConfigManager{
|
|
updateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
|
return cfg, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
body := map[string]any{
|
|
"name": "shared-proxy",
|
|
"type": "tcp",
|
|
"tcp": map[string]any{
|
|
"localPort": 10080,
|
|
"remotePort": 7000,
|
|
},
|
|
}
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal request: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(data))
|
|
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
|
|
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
|
|
|
resp, err := controller.UpdateStoreProxy(ctx)
|
|
if err != nil {
|
|
t.Fatalf("update store proxy: %v", err)
|
|
}
|
|
payload, ok := resp.(model.ProxyDefinition)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type: %T", resp)
|
|
}
|
|
if payload.TCP == nil || payload.TCP.RemotePort != 7000 {
|
|
t.Fatalf("unexpected response payload: %#v", payload)
|
|
}
|
|
}
|