mirror of
https://github.com/fatedier/frp.git
synced 2026-03-17 23:39:16 +08:00
Compare commits
12 Commits
605f3bdece
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cdef90113 | ||
|
|
85e8e2c830 | ||
|
|
ff4ad2f907 | ||
|
|
94a631fe9c | ||
|
|
6b1be922e1 | ||
|
|
4f584f81d0 | ||
|
|
9669e1ca0c | ||
|
|
48e8901466 | ||
|
|
bcd2424c24 | ||
|
|
c7ac12ea0f | ||
|
|
eeb0dacfc1 | ||
|
|
535eb3db35 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -29,6 +29,6 @@ client.key
|
||||
*.swp
|
||||
|
||||
# AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.superpowers/
|
||||
|
||||
@@ -90,6 +90,7 @@ linters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
@@ -112,6 +113,7 @@ formatters:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- node_modules
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
34
AGENTS.md
Normal file
34
AGENTS.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Build
|
||||
- `make build` - Build both frps and frpc binaries
|
||||
- `make frps` - Build server binary only
|
||||
- `make frpc` - Build client binary only
|
||||
- `make all` - Build everything with formatting
|
||||
|
||||
### Testing
|
||||
- `make test` - Run unit tests
|
||||
- `make e2e` - Run end-to-end tests
|
||||
- `make e2e-trace` - Run e2e tests with trace logging
|
||||
- `make alltest` - Run all tests including vet, unit tests, and e2e
|
||||
|
||||
### Code Quality
|
||||
- `make fmt` - Run go fmt
|
||||
- `make fmt-more` - Run gofumpt for more strict formatting
|
||||
- `make gci` - Run gci import organizer
|
||||
- `make vet` - Run go vet
|
||||
- `golangci-lint run` - Run comprehensive linting (configured in .golangci.yml)
|
||||
|
||||
### Assets
|
||||
- `make web` - Build web dashboards (frps and frpc)
|
||||
|
||||
### Cleanup
|
||||
- `make clean` - Remove built binaries and temporary files
|
||||
|
||||
## Testing
|
||||
|
||||
- E2E tests using Ginkgo/Gomega framework
|
||||
- Mock servers in `/test/e2e/mock/`
|
||||
- Run: `make e2e` or `make alltest`
|
||||
@@ -7,3 +7,4 @@
|
||||
* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
|
||||
* Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded.
|
||||
* Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers.
|
||||
* OIDC auth now caches the access token and refreshes it before expiry, avoiding a new token request on every heartbeat. Falls back to per-request fetch when the provider omits `expires_in`.
|
||||
|
||||
@@ -38,6 +38,8 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
||||
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
||||
subRouter.HandleFunc("/api/proxy/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/visitor/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet)
|
||||
|
||||
if svr.storeSource != nil {
|
||||
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
|
||||
|
||||
@@ -80,6 +80,48 @@ func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
|
||||
return m.svr.getAllProxyStatus()
|
||||
}
|
||||
|
||||
func (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
|
||||
// Try running proxy manager first
|
||||
ws, ok := m.svr.getProxyStatus(name)
|
||||
if ok {
|
||||
return ws.Cfg, true
|
||||
}
|
||||
|
||||
// Fallback to store
|
||||
m.svr.reloadMu.Lock()
|
||||
storeSource := m.svr.storeSource
|
||||
m.svr.reloadMu.Unlock()
|
||||
|
||||
if storeSource != nil {
|
||||
cfg := storeSource.GetProxy(name)
|
||||
if cfg != nil {
|
||||
return cfg, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
|
||||
// Try running visitor manager first
|
||||
cfg, ok := m.svr.getVisitorCfg(name)
|
||||
if ok {
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
// Fallback to store
|
||||
m.svr.reloadMu.Lock()
|
||||
storeSource := m.svr.storeSource
|
||||
m.svr.reloadMu.Unlock()
|
||||
|
||||
if storeSource != nil {
|
||||
vcfg := storeSource.GetVisitor(name)
|
||||
if vcfg != nil {
|
||||
return vcfg, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
|
||||
@@ -26,6 +26,9 @@ type ConfigManager interface {
|
||||
IsStoreProxyEnabled(name string) bool
|
||||
StoreEnabled() bool
|
||||
|
||||
GetProxyConfig(name string) (v1.ProxyConfigurer, bool)
|
||||
GetVisitorConfig(name string) (v1.VisitorConfigurer, bool)
|
||||
|
||||
ListStoreProxies() ([]v1.ProxyConfigurer, error)
|
||||
GetStoreProxy(name string) (v1.ProxyConfigurer, error)
|
||||
CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
||||
|
||||
@@ -162,6 +162,44 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro
|
||||
return psr
|
||||
}
|
||||
|
||||
// GetProxyConfig handles GET /api/proxy/{name}/config
|
||||
func (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) {
|
||||
name := ctx.Param("name")
|
||||
if name == "" {
|
||||
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
|
||||
}
|
||||
|
||||
cfg, ok := c.manager.GetProxyConfig(name)
|
||||
if !ok {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name))
|
||||
}
|
||||
|
||||
payload, err := model.ProxyDefinitionFromConfigurer(cfg)
|
||||
if err != nil {
|
||||
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// GetVisitorConfig handles GET /api/visitor/{name}/config
|
||||
func (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) {
|
||||
name := ctx.Param("name")
|
||||
if name == "" {
|
||||
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
|
||||
}
|
||||
|
||||
cfg, ok := c.manager.GetVisitorConfig(name)
|
||||
if !ok {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name))
|
||||
}
|
||||
|
||||
payload, err := model.VisitorDefinitionFromConfigurer(cfg)
|
||||
if err != nil {
|
||||
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
|
||||
proxies, err := c.manager.ListStoreProxies()
|
||||
if err != nil {
|
||||
|
||||
@@ -26,6 +26,8 @@ type fakeConfigManager struct {
|
||||
getProxyStatusFn func() []*proxy.WorkingStatus
|
||||
isStoreProxyEnabledFn func(name string) bool
|
||||
storeEnabledFn func() bool
|
||||
getProxyConfigFn func(name string) (v1.ProxyConfigurer, bool)
|
||||
getVisitorConfigFn func(name string) (v1.VisitorConfigurer, bool)
|
||||
|
||||
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
|
||||
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
|
||||
@@ -82,6 +84,20 @@ func (m *fakeConfigManager) StoreEnabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
|
||||
if m.getProxyConfigFn != nil {
|
||||
return m.getProxyConfigFn(name)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
|
||||
if m.getVisitorConfigFn != nil {
|
||||
return m.getVisitorConfigFn(name)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
|
||||
if m.listStoreProxiesFn != nil {
|
||||
return m.listStoreProxiesFn()
|
||||
@@ -529,3 +545,118 @@ func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
|
||||
t.Fatalf("unexpected response payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProxyConfigFromManager(t *testing.T) {
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
|
||||
if name == "ssh" {
|
||||
cfg := &v1.TCPProxyConfig{
|
||||
ProxyBaseConfig: v1.ProxyBaseConfig{
|
||||
Name: "ssh",
|
||||
Type: "tcp",
|
||||
ProxyBackend: v1.ProxyBackend{
|
||||
LocalPort: 22,
|
||||
},
|
||||
},
|
||||
}
|
||||
return cfg, true
|
||||
}
|
||||
return nil, false
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/proxy/ssh/config", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "ssh"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
resp, err := controller.GetProxyConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get proxy config: %v", err)
|
||||
}
|
||||
payload, ok := resp.(model.ProxyDefinition)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type: %T", resp)
|
||||
}
|
||||
if payload.Name != "ssh" || payload.Type != "tcp" || payload.TCP == nil {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProxyConfigNotFound(t *testing.T) {
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
|
||||
return nil, false
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/proxy/missing/config", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "missing"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
_, err := controller.GetProxyConfig(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
assertHTTPCode(t, err, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestGetVisitorConfigFromManager(t *testing.T) {
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
|
||||
if name == "my-stcp" {
|
||||
cfg := &v1.STCPVisitorConfig{
|
||||
VisitorBaseConfig: v1.VisitorBaseConfig{
|
||||
Name: "my-stcp",
|
||||
Type: "stcp",
|
||||
ServerName: "server1",
|
||||
BindPort: 9000,
|
||||
},
|
||||
}
|
||||
return cfg, true
|
||||
}
|
||||
return nil, false
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/visitor/my-stcp/config", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "my-stcp"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
resp, err := controller.GetVisitorConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get visitor config: %v", err)
|
||||
}
|
||||
payload, ok := resp.(model.VisitorDefinition)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type: %T", resp)
|
||||
}
|
||||
if payload.Name != "my-stcp" || payload.Type != "stcp" || payload.STCP == nil {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVisitorConfigNotFound(t *testing.T) {
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
|
||||
return nil, false
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/visitor/missing/config", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "missing"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
_, err := controller.GetVisitorConfig(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
assertHTTPCode(t, err, http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
@@ -162,15 +163,6 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var webServer *httppkg.Server
|
||||
if options.Common.WebServer.Port > 0 {
|
||||
ws, err := httppkg.NewServer(options.Common.WebServer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webServer = ws
|
||||
}
|
||||
|
||||
authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -191,6 +183,17 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||
|
||||
// Create the web server after all fallible steps so its listener is not
|
||||
// leaked when an earlier error causes NewService to return.
|
||||
var webServer *httppkg.Server
|
||||
if options.Common.WebServer.Port > 0 {
|
||||
ws, err := httppkg.NewServer(options.Common.WebServer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webServer = ws
|
||||
}
|
||||
|
||||
s := &Service{
|
||||
ctx: context.Background(),
|
||||
auth: authRuntime,
|
||||
@@ -229,22 +232,25 @@ func (svr *Service) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if svr.vnetController != nil {
|
||||
vnetController := svr.vnetController
|
||||
if err := svr.vnetController.Init(); err != nil {
|
||||
log.Errorf("init virtual network controller error: %v", err)
|
||||
svr.stop()
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
log.Infof("virtual network controller start...")
|
||||
if err := svr.vnetController.Run(); err != nil {
|
||||
if err := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Warnf("virtual network controller exit with error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if svr.webServer != nil {
|
||||
webServer := svr.webServer
|
||||
go func() {
|
||||
log.Infof("admin server listen on %s", svr.webServer.Address())
|
||||
if err := svr.webServer.Run(); err != nil {
|
||||
log.Infof("admin server listen on %s", webServer.Address())
|
||||
if err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Warnf("admin server exit with error: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -255,6 +261,7 @@ func (svr *Service) Run(ctx context.Context) error {
|
||||
if svr.ctl == nil {
|
||||
cancelCause := cancelErr{}
|
||||
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
|
||||
svr.stop()
|
||||
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
|
||||
}
|
||||
|
||||
@@ -497,6 +504,10 @@ func (svr *Service) stop() {
|
||||
svr.webServer.Close()
|
||||
svr.webServer = nil
|
||||
}
|
||||
if svr.vnetController != nil {
|
||||
_ = svr.vnetController.Stop()
|
||||
svr.vnetController = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
||||
@@ -510,6 +521,17 @@ func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
|
||||
return ctl.pm.GetProxyStatus(name)
|
||||
}
|
||||
|
||||
func (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
|
||||
svr.ctlMu.RLock()
|
||||
ctl := svr.ctl
|
||||
svr.ctlMu.RUnlock()
|
||||
|
||||
if ctl == nil {
|
||||
return nil, false
|
||||
}
|
||||
return ctl.vm.GetVisitorCfg(name)
|
||||
}
|
||||
|
||||
func (svr *Service) StatusExporter() StatusExporter {
|
||||
return &statusExporterImpl{
|
||||
getProxyStatusFunc: svr.getProxyStatus,
|
||||
|
||||
@@ -1,14 +1,120 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
type failingConnector struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *failingConnector) Open() error {
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *failingConnector) Connect() (net.Conn, error) {
|
||||
return nil, c.err
|
||||
}
|
||||
|
||||
func (c *failingConnector) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFreeTCPPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen on ephemeral port: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
return ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
|
||||
port := getFreeTCPPort(t)
|
||||
agg := source.NewAggregator(source.NewConfigSource())
|
||||
|
||||
svr, err := NewService(ServiceOptions{
|
||||
Common: &v1.ClientCommonConfig{
|
||||
LoginFailExit: lo.ToPtr(true),
|
||||
WebServer: v1.WebServerConfig{
|
||||
Addr: "127.0.0.1",
|
||||
Port: port,
|
||||
},
|
||||
},
|
||||
ConfigSourceAggregator: agg,
|
||||
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
|
||||
return &failingConnector{err: errors.New("login boom")}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new service: %v", err)
|
||||
}
|
||||
|
||||
err = svr.Run(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected run error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "login boom") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if svr.webServer != nil {
|
||||
t.Fatal("expected web server to be cleaned up after initial login failure")
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
t.Fatalf("expected admin port to be released: %v", err)
|
||||
}
|
||||
_ = ln.Close()
|
||||
}
|
||||
|
||||
func TestNewServiceDoesNotLeakAdminListenerOnAuthBuildFailure(t *testing.T) {
|
||||
port := getFreeTCPPort(t)
|
||||
agg := source.NewAggregator(source.NewConfigSource())
|
||||
|
||||
_, err := NewService(ServiceOptions{
|
||||
Common: &v1.ClientCommonConfig{
|
||||
Auth: v1.AuthClientConfig{
|
||||
Method: v1.AuthMethodOIDC,
|
||||
OIDC: v1.AuthOIDCClientConfig{
|
||||
TokenEndpointURL: "://bad",
|
||||
},
|
||||
},
|
||||
WebServer: v1.WebServerConfig{
|
||||
Addr: "127.0.0.1",
|
||||
Port: port,
|
||||
},
|
||||
},
|
||||
ConfigSourceAggregator: agg,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected new service error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "auth.oidc.tokenEndpointURL") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
t.Fatalf("expected admin port to remain free: %v", err)
|
||||
}
|
||||
_ = ln.Close()
|
||||
}
|
||||
|
||||
func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
|
||||
prevCommon := &v1.ClientCommonConfig{User: "old-user"}
|
||||
newCommon := &v1.ClientCommonConfig{User: "new-user"}
|
||||
|
||||
@@ -191,6 +191,13 @@ func (vm *Manager) TransferConn(name string, conn net.Conn) error {
|
||||
return v.AcceptConn(conn)
|
||||
}
|
||||
|
||||
func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
|
||||
vm.mu.RLock()
|
||||
defer vm.mu.RUnlock()
|
||||
cfg, ok := vm.cfgs[name]
|
||||
return cfg, ok
|
||||
}
|
||||
|
||||
type visitorHelperImpl struct {
|
||||
connectServerFn func() (net.Conn, error)
|
||||
msgTransporter transport.MessageTransporter
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
|
||||
@@ -75,14 +76,64 @@ func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyUR
|
||||
return &http.Client{Transport: transport}, nil
|
||||
}
|
||||
|
||||
// nonCachingTokenSource wraps a clientcredentials.Config to fetch a fresh
|
||||
// token on every call. This is used as a fallback when the OIDC provider
|
||||
// does not return expires_in, which would cause a caching TokenSource to
|
||||
// hold onto a stale token forever.
|
||||
type nonCachingTokenSource struct {
|
||||
cfg *clientcredentials.Config
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (s *nonCachingTokenSource) Token() (*oauth2.Token, error) {
|
||||
return s.cfg.Token(s.ctx)
|
||||
}
|
||||
|
||||
// oidcTokenSource wraps a caching oauth2.TokenSource and, on the first
|
||||
// successful Token() call, checks whether the provider returns an expiry.
|
||||
// If not, it permanently switches to nonCachingTokenSource so that a fresh
|
||||
// token is fetched every time. This avoids an eager network call at
|
||||
// construction time, letting the login retry loop handle transient IdP
|
||||
// outages.
|
||||
type oidcTokenSource struct {
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
source oauth2.TokenSource
|
||||
fallbackCfg *clientcredentials.Config
|
||||
fallbackCtx context.Context
|
||||
}
|
||||
|
||||
func (s *oidcTokenSource) Token() (*oauth2.Token, error) {
|
||||
s.mu.Lock()
|
||||
if !s.initialized {
|
||||
token, err := s.source.Token()
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if token.Expiry.IsZero() {
|
||||
s.source = &nonCachingTokenSource{cfg: s.fallbackCfg, ctx: s.fallbackCtx}
|
||||
}
|
||||
s.initialized = true
|
||||
s.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
source := s.source
|
||||
s.mu.Unlock()
|
||||
return source.Token()
|
||||
}
|
||||
|
||||
type OidcAuthProvider struct {
|
||||
additionalAuthScopes []v1.AuthScope
|
||||
|
||||
tokenGenerator *clientcredentials.Config
|
||||
httpClient *http.Client
|
||||
tokenSource oauth2.TokenSource
|
||||
}
|
||||
|
||||
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
|
||||
if err := validation.ValidateOIDCClientCredentialsConfig(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
eps := make(map[string][]string)
|
||||
for k, v := range cfg.AdditionalEndpointParams {
|
||||
eps[k] = []string{v}
|
||||
@@ -100,30 +151,42 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
|
||||
EndpointParams: eps,
|
||||
}
|
||||
|
||||
// Create custom HTTP client if needed
|
||||
var httpClient *http.Client
|
||||
// Build the context that TokenSource will use for all future HTTP requests.
|
||||
// context.Background() is appropriate here because the token source is
|
||||
// long-lived and outlives any single request.
|
||||
ctx := context.Background()
|
||||
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
|
||||
var err error
|
||||
httpClient, err = createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
|
||||
httpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
|
||||
}
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
}
|
||||
|
||||
// Create a persistent TokenSource that caches the token and refreshes
|
||||
// it before expiry. This avoids making a new HTTP request to the OIDC
|
||||
// provider on every heartbeat/ping.
|
||||
//
|
||||
// We wrap it in an oidcTokenSource so that the first Token() call
|
||||
// (deferred to SetLogin inside the login retry loop) probes whether the
|
||||
// provider returns expires_in. If not, it switches to a non-caching
|
||||
// source. This avoids an eager network call at construction time, which
|
||||
// would prevent loopLoginUntilSuccess from retrying on transient IdP
|
||||
// outages.
|
||||
cachingSource := tokenGenerator.TokenSource(ctx)
|
||||
|
||||
return &OidcAuthProvider{
|
||||
additionalAuthScopes: additionalAuthScopes,
|
||||
tokenGenerator: tokenGenerator,
|
||||
httpClient: httpClient,
|
||||
tokenSource: &oidcTokenSource{
|
||||
source: cachingSource,
|
||||
fallbackCfg: tokenGenerator,
|
||||
fallbackCtx: ctx,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
|
||||
ctx := context.Background()
|
||||
if auth.httpClient != nil {
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, auth.httpClient)
|
||||
}
|
||||
|
||||
tokenObj, err := auth.tokenGenerator.Token(ctx)
|
||||
tokenObj, err := auth.tokenSource.Token()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -62,3 +66,188 @@ func TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) {
|
||||
r.Error(err)
|
||||
r.Contains(err.Error(), "received different OIDC subject in login and ping")
|
||||
}
|
||||
|
||||
func TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) {
|
||||
r := require.New(t)
|
||||
|
||||
var requestCount atomic.Int32
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
requestCount.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||
"access_token": "fresh-test-token",
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
provider, err := auth.NewOidcAuthSetter(
|
||||
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||
v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
},
|
||||
)
|
||||
r.NoError(err)
|
||||
|
||||
// Constructor no longer fetches a token eagerly.
|
||||
// The first SetLogin triggers the adaptive probe.
|
||||
r.Equal(int32(0), requestCount.Load())
|
||||
|
||||
loginMsg := &msg.Login{}
|
||||
err = provider.SetLogin(loginMsg)
|
||||
r.NoError(err)
|
||||
r.Equal("fresh-test-token", loginMsg.PrivilegeKey)
|
||||
|
||||
for range 3 {
|
||||
pingMsg := &msg.Ping{}
|
||||
err = provider.SetPing(pingMsg)
|
||||
r.NoError(err)
|
||||
r.Equal("fresh-test-token", pingMsg.PrivilegeKey)
|
||||
}
|
||||
|
||||
// 1 probe (login) + 3 pings = 4 requests (probe doubles as the login token fetch)
|
||||
r.Equal(int32(4), requestCount.Load(), "each call should fetch a fresh token when expires_in is missing")
|
||||
}
|
||||
|
||||
func TestOidcAuthProviderCachesToken(t *testing.T) {
|
||||
r := require.New(t)
|
||||
|
||||
var requestCount atomic.Int32
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
requestCount.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||
"access_token": "cached-test-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
provider, err := auth.NewOidcAuthSetter(
|
||||
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||
v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
},
|
||||
)
|
||||
r.NoError(err)
|
||||
|
||||
// Constructor no longer fetches eagerly; first SetLogin triggers the probe.
|
||||
r.Equal(int32(0), requestCount.Load())
|
||||
|
||||
// SetLogin triggers the adaptive probe and caches the token.
|
||||
loginMsg := &msg.Login{}
|
||||
err = provider.SetLogin(loginMsg)
|
||||
r.NoError(err)
|
||||
r.Equal("cached-test-token", loginMsg.PrivilegeKey)
|
||||
r.Equal(int32(1), requestCount.Load())
|
||||
|
||||
// Subsequent calls should also reuse the cached token
|
||||
for range 5 {
|
||||
pingMsg := &msg.Ping{}
|
||||
err = provider.SetPing(pingMsg)
|
||||
r.NoError(err)
|
||||
r.Equal("cached-test-token", pingMsg.PrivilegeKey)
|
||||
}
|
||||
r.Equal(int32(1), requestCount.Load(), "token endpoint should only be called once; cached token should be reused")
|
||||
}
|
||||
|
||||
func TestOidcAuthProviderRetriesOnInitialFailure(t *testing.T) {
|
||||
r := require.New(t)
|
||||
|
||||
var requestCount atomic.Int32
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
n := requestCount.Add(1)
|
||||
// The oauth2 library retries once internally, so we need two
|
||||
// consecutive failures to surface an error to the caller.
|
||||
if n <= 2 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "temporarily_unavailable",
|
||||
"error_description": "service is starting up",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
|
||||
"access_token": "retry-test-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
// Constructor succeeds even though the IdP is "down".
|
||||
provider, err := auth.NewOidcAuthSetter(
|
||||
[]v1.AuthScope{v1.AuthScopeHeartBeats},
|
||||
v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
},
|
||||
)
|
||||
r.NoError(err)
|
||||
r.Equal(int32(0), requestCount.Load())
|
||||
|
||||
// First SetLogin hits the IdP, which returns an error (after internal retry).
|
||||
loginMsg := &msg.Login{}
|
||||
err = provider.SetLogin(loginMsg)
|
||||
r.Error(err)
|
||||
r.Equal(int32(2), requestCount.Load())
|
||||
|
||||
// Second SetLogin retries and succeeds.
|
||||
err = provider.SetLogin(loginMsg)
|
||||
r.NoError(err)
|
||||
r.Equal("retry-test-token", loginMsg.PrivilegeKey)
|
||||
r.Equal(int32(3), requestCount.Load())
|
||||
|
||||
// Subsequent calls use cached token.
|
||||
pingMsg := &msg.Ping{}
|
||||
err = provider.SetPing(pingMsg)
|
||||
r.NoError(err)
|
||||
r.Equal("retry-test-token", pingMsg.PrivilegeKey)
|
||||
r.Equal(int32(3), requestCount.Load())
|
||||
}
|
||||
|
||||
func TestNewOidcAuthSetterRejectsInvalidStaticConfig(t *testing.T) {
|
||||
r := require.New(t)
|
||||
tokenServer := httptest.NewServer(http.NotFoundHandler())
|
||||
defer tokenServer.Close()
|
||||
|
||||
_, err := auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: "://bad",
|
||||
})
|
||||
r.Error(err)
|
||||
r.Contains(err.Error(), "auth.oidc.tokenEndpointURL")
|
||||
|
||||
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
})
|
||||
r.Error(err)
|
||||
r.Contains(err.Error(), "auth.oidc.clientID is required")
|
||||
|
||||
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
AdditionalEndpointParams: map[string]string{
|
||||
"scope": "profile",
|
||||
},
|
||||
})
|
||||
r.Error(err)
|
||||
r.Contains(err.Error(), "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||
|
||||
_, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
Audience: "api",
|
||||
AdditionalEndpointParams: map[string]string{"audience": "override"},
|
||||
})
|
||||
r.Error(err)
|
||||
r.Contains(err.Error(), "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||
}
|
||||
|
||||
@@ -88,6 +88,11 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
|
||||
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
if c.Method == v1.AuthMethodOIDC && c.OIDC.TokenSource == nil {
|
||||
if err := ValidateOIDCClientCredentialsConfig(&c.OIDC); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
}
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
|
||||
57
pkg/config/v1/validation/oidc.go
Normal file
57
pkg/config/v1/validation/oidc.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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 validation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error {
|
||||
var errs []string
|
||||
|
||||
if c.ClientID == "" {
|
||||
errs = append(errs, "auth.oidc.clientID is required")
|
||||
}
|
||||
|
||||
if c.TokenEndpointURL == "" {
|
||||
errs = append(errs, "auth.oidc.tokenEndpointURL is required")
|
||||
} else {
|
||||
tokenURL, err := url.Parse(c.TokenEndpointURL)
|
||||
if err != nil || !tokenURL.IsAbs() || tokenURL.Host == "" {
|
||||
errs = append(errs, "auth.oidc.tokenEndpointURL must be an absolute http or https URL")
|
||||
} else if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
|
||||
errs = append(errs, "auth.oidc.tokenEndpointURL must use http or https")
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := c.AdditionalEndpointParams["scope"]; ok {
|
||||
errs = append(errs, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||
}
|
||||
|
||||
if c.Audience != "" {
|
||||
if _, ok := c.AdditionalEndpointParams["audience"]; ok {
|
||||
errs = append(errs, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
78
pkg/config/v1/validation/oidc_test.go
Normal file
78
pkg/config/v1/validation/oidc_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// 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 validation
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestValidateOIDCClientCredentialsConfig(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.NotFoundHandler())
|
||||
defer tokenServer.Close()
|
||||
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
require.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
AdditionalEndpointParams: map[string]string{
|
||||
"resource": "api",
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("invalid token endpoint url", func(t *testing.T) {
|
||||
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: "://bad",
|
||||
})
|
||||
require.ErrorContains(t, err, "auth.oidc.tokenEndpointURL")
|
||||
})
|
||||
|
||||
t.Run("missing client id", func(t *testing.T) {
|
||||
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
})
|
||||
require.ErrorContains(t, err, "auth.oidc.clientID is required")
|
||||
})
|
||||
|
||||
t.Run("scope endpoint param is not allowed", func(t *testing.T) {
|
||||
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
AdditionalEndpointParams: map[string]string{
|
||||
"scope": "email",
|
||||
},
|
||||
})
|
||||
require.ErrorContains(t, err, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||
})
|
||||
|
||||
t.Run("audience conflict", func(t *testing.T) {
|
||||
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
Audience: "api",
|
||||
AdditionalEndpointParams: map[string]string{
|
||||
"audience": "override",
|
||||
},
|
||||
})
|
||||
require.ErrorContains(t, err, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||
})
|
||||
}
|
||||
@@ -143,7 +143,6 @@ func (m *serverMetrics) OpenConnection(name string, _ string) {
|
||||
proxyStats, ok := m.info.ProxyStatistics[name]
|
||||
if ok {
|
||||
proxyStats.CurConns.Inc(1)
|
||||
m.info.ProxyStatistics[name] = proxyStats
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +154,6 @@ func (m *serverMetrics) CloseConnection(name string, _ string) {
|
||||
proxyStats, ok := m.info.ProxyStatistics[name]
|
||||
if ok {
|
||||
proxyStats.CurConns.Dec(1)
|
||||
m.info.ProxyStatistics[name] = proxyStats
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +166,6 @@ func (m *serverMetrics) AddTrafficIn(name string, _ string, trafficBytes int64)
|
||||
proxyStats, ok := m.info.ProxyStatistics[name]
|
||||
if ok {
|
||||
proxyStats.TrafficIn.Inc(trafficBytes)
|
||||
m.info.ProxyStatistics[name] = proxyStats
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +178,6 @@ func (m *serverMetrics) AddTrafficOut(name string, _ string, trafficBytes int64)
|
||||
proxyStats, ok := m.info.ProxyStatistics[name]
|
||||
if ok {
|
||||
proxyStats.TrafficOut.Inc(trafficBytes)
|
||||
m.info.ProxyStatistics[name] = proxyStats
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,15 +236,9 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for name, proxyStats := range m.info.ProxyStatistics {
|
||||
if proxyStats.ProxyType != proxyType {
|
||||
continue
|
||||
}
|
||||
if name != proxyName {
|
||||
continue
|
||||
}
|
||||
res = toProxyStats(name, proxyStats)
|
||||
break
|
||||
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||
if ok && proxyStats.ProxyType == proxyType {
|
||||
res = toProxyStats(proxyName, proxyStats)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,8 +93,7 @@ type featureGate struct {
|
||||
|
||||
// NewFeatureGate creates a new feature gate with the default features
|
||||
func NewFeatureGate() MutableFeatureGate {
|
||||
known := map[Feature]FeatureSpec{}
|
||||
maps.Copy(known, defaultFeatures)
|
||||
known := maps.Clone(defaultFeatures)
|
||||
|
||||
f := &featureGate{}
|
||||
f.known.Store(known)
|
||||
@@ -108,10 +107,8 @@ func (f *featureGate) SetFromMap(m map[string]bool) error {
|
||||
defer f.lock.Unlock()
|
||||
|
||||
// Copy existing state
|
||||
known := map[Feature]FeatureSpec{}
|
||||
maps.Copy(known, f.known.Load().(map[Feature]FeatureSpec))
|
||||
enabled := map[Feature]bool{}
|
||||
maps.Copy(enabled, f.enabled.Load().(map[Feature]bool))
|
||||
known := maps.Clone(f.known.Load().(map[Feature]FeatureSpec))
|
||||
enabled := maps.Clone(f.enabled.Load().(map[Feature]bool))
|
||||
|
||||
// Apply the new settings
|
||||
for k, v := range m {
|
||||
@@ -142,8 +139,7 @@ func (f *featureGate) Add(features map[Feature]FeatureSpec) error {
|
||||
}
|
||||
|
||||
// Copy existing state
|
||||
known := map[Feature]FeatureSpec{}
|
||||
maps.Copy(known, f.known.Load().(map[Feature]FeatureSpec))
|
||||
known := maps.Clone(f.known.Load().(map[Feature]FeatureSpec))
|
||||
|
||||
// Add new features
|
||||
for name, spec := range features {
|
||||
|
||||
@@ -100,7 +100,11 @@ func (s *Server) Run() error {
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
return s.hs.Close()
|
||||
err := s.hs.Close()
|
||||
if s.ln != nil {
|
||||
_ = s.ln.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type RouterRegisterHelper struct {
|
||||
|
||||
@@ -131,6 +131,9 @@ func (c *Controller) handlePacket(buf []byte) {
|
||||
}
|
||||
|
||||
func (c *Controller) Stop() error {
|
||||
if c.tun == nil {
|
||||
return nil
|
||||
}
|
||||
return c.tun.Close()
|
||||
}
|
||||
|
||||
|
||||
77
server/group/base.go
Normal file
77
server/group/base.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
gerr "github.com/fatedier/golib/errors"
|
||||
)
|
||||
|
||||
// baseGroup contains the shared plumbing for listener-based groups
|
||||
// (TCP, HTTPS, TCPMux). Each concrete group embeds this and provides
|
||||
// its own Listen method with protocol-specific validation.
|
||||
type baseGroup struct {
|
||||
group string
|
||||
groupKey string
|
||||
|
||||
acceptCh chan net.Conn
|
||||
realLn net.Listener
|
||||
lns []*Listener
|
||||
mu sync.Mutex
|
||||
cleanupFn func()
|
||||
}
|
||||
|
||||
// initBase resets the baseGroup for a fresh listen cycle.
|
||||
// Must be called under mu when len(lns) == 0.
|
||||
func (bg *baseGroup) initBase(group, groupKey string, realLn net.Listener, cleanupFn func()) {
|
||||
bg.group = group
|
||||
bg.groupKey = groupKey
|
||||
bg.realLn = realLn
|
||||
bg.acceptCh = make(chan net.Conn)
|
||||
bg.cleanupFn = cleanupFn
|
||||
}
|
||||
|
||||
// worker reads from the real listener and fans out to acceptCh.
|
||||
// The parameters are captured at creation time so that the worker is
|
||||
// bound to a specific listen cycle and cannot observe a later initBase.
|
||||
func (bg *baseGroup) worker(realLn net.Listener, acceptCh chan<- net.Conn) {
|
||||
for {
|
||||
c, err := realLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gerr.PanicToError(func() {
|
||||
acceptCh <- c
|
||||
})
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newListener creates a new Listener wired to this baseGroup.
|
||||
// Must be called under mu.
|
||||
func (bg *baseGroup) newListener(addr net.Addr) *Listener {
|
||||
ln := newListener(bg.acceptCh, addr, bg.closeListener)
|
||||
bg.lns = append(bg.lns, ln)
|
||||
return ln
|
||||
}
|
||||
|
||||
// closeListener removes ln from the list. When the last listener is removed,
|
||||
// it closes acceptCh, closes the real listener, and calls cleanupFn.
|
||||
func (bg *baseGroup) closeListener(ln *Listener) {
|
||||
bg.mu.Lock()
|
||||
defer bg.mu.Unlock()
|
||||
for i, l := range bg.lns {
|
||||
if l == ln {
|
||||
bg.lns = append(bg.lns[:i], bg.lns[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(bg.lns) == 0 {
|
||||
close(bg.acceptCh)
|
||||
bg.realLn.Close()
|
||||
bg.cleanupFn()
|
||||
}
|
||||
}
|
||||
169
server/group/base_test.go
Normal file
169
server/group/base_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeLn is a controllable net.Listener for tests.
|
||||
type fakeLn struct {
|
||||
connCh chan net.Conn
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newFakeLn() *fakeLn {
|
||||
return &fakeLn{
|
||||
connCh: make(chan net.Conn, 8),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeLn) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-f.connCh:
|
||||
return c, nil
|
||||
case <-f.closed:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeLn) Close() error {
|
||||
f.once.Do(func() { close(f.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLn) Addr() net.Addr { return fakeAddr("127.0.0.1:9999") }
|
||||
|
||||
func (f *fakeLn) inject(c net.Conn) {
|
||||
select {
|
||||
case f.connCh <- c:
|
||||
case <-f.closed:
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseGroup_WorkerFanOut(t *testing.T) {
|
||||
fl := newFakeLn()
|
||||
var bg baseGroup
|
||||
bg.initBase("g", "key", fl, func() {})
|
||||
|
||||
go bg.worker(fl, bg.acceptCh)
|
||||
|
||||
c1, c2 := net.Pipe()
|
||||
defer c2.Close()
|
||||
fl.inject(c1)
|
||||
|
||||
select {
|
||||
case got := <-bg.acceptCh:
|
||||
assert.Equal(t, c1, got)
|
||||
got.Close()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for connection on acceptCh")
|
||||
}
|
||||
|
||||
fl.Close()
|
||||
}
|
||||
|
||||
func TestBaseGroup_WorkerStopsOnListenerClose(t *testing.T) {
|
||||
fl := newFakeLn()
|
||||
var bg baseGroup
|
||||
bg.initBase("g", "key", fl, func() {})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
bg.worker(fl, bg.acceptCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
fl.Close()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("worker did not stop after listener close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseGroup_WorkerClosesConnOnClosedChannel(t *testing.T) {
|
||||
fl := newFakeLn()
|
||||
var bg baseGroup
|
||||
bg.initBase("g", "key", fl, func() {})
|
||||
|
||||
// Close acceptCh before worker sends.
|
||||
close(bg.acceptCh)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
bg.worker(fl, bg.acceptCh)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
c1, c2 := net.Pipe()
|
||||
defer c2.Close()
|
||||
fl.inject(c1)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("worker did not stop after panic recovery")
|
||||
}
|
||||
|
||||
// c1 should have been closed by worker's panic recovery path.
|
||||
buf := make([]byte, 1)
|
||||
_, err := c1.Read(buf)
|
||||
assert.Error(t, err, "connection should be closed by worker")
|
||||
}
|
||||
|
||||
func TestBaseGroup_CloseLastListenerTriggersCleanup(t *testing.T) {
|
||||
fl := newFakeLn()
|
||||
var bg baseGroup
|
||||
cleanupCalled := 0
|
||||
bg.initBase("g", "key", fl, func() { cleanupCalled++ })
|
||||
|
||||
bg.mu.Lock()
|
||||
ln1 := bg.newListener(fl.Addr())
|
||||
ln2 := bg.newListener(fl.Addr())
|
||||
bg.mu.Unlock()
|
||||
|
||||
go bg.worker(fl, bg.acceptCh)
|
||||
|
||||
ln1.Close()
|
||||
assert.Equal(t, 0, cleanupCalled, "cleanup should not run while listeners remain")
|
||||
|
||||
ln2.Close()
|
||||
assert.Equal(t, 1, cleanupCalled, "cleanup should run after last listener closed")
|
||||
}
|
||||
|
||||
func TestBaseGroup_CloseOneOfTwoListeners(t *testing.T) {
|
||||
fl := newFakeLn()
|
||||
var bg baseGroup
|
||||
cleanupCalled := 0
|
||||
bg.initBase("g", "key", fl, func() { cleanupCalled++ })
|
||||
|
||||
bg.mu.Lock()
|
||||
ln1 := bg.newListener(fl.Addr())
|
||||
ln2 := bg.newListener(fl.Addr())
|
||||
bg.mu.Unlock()
|
||||
|
||||
go bg.worker(fl, bg.acceptCh)
|
||||
|
||||
ln1.Close()
|
||||
assert.Equal(t, 0, cleanupCalled)
|
||||
|
||||
// ln2 should still receive connections.
|
||||
c1, c2 := net.Pipe()
|
||||
defer c2.Close()
|
||||
fl.inject(c1)
|
||||
|
||||
got, err := ln2.Accept()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c1, got)
|
||||
got.Close()
|
||||
|
||||
ln2.Close()
|
||||
assert.Equal(t, 1, cleanupCalled)
|
||||
}
|
||||
@@ -24,4 +24,6 @@ var (
|
||||
ErrListenerClosed = errors.New("group listener closed")
|
||||
ErrGroupDifferentPort = errors.New("group should have same remote port")
|
||||
ErrProxyRepeated = errors.New("group proxy repeated")
|
||||
|
||||
errGroupStale = errors.New("stale group reference")
|
||||
)
|
||||
|
||||
@@ -9,53 +9,42 @@ import (
|
||||
"github.com/fatedier/frp/pkg/util/vhost"
|
||||
)
|
||||
|
||||
// HTTPGroupController manages HTTP groups that use round-robin
|
||||
// callback routing (fundamentally different from listener-based groups).
|
||||
type HTTPGroupController struct {
|
||||
// groups indexed by group name
|
||||
groups map[string]*HTTPGroup
|
||||
|
||||
// register createConn for each group to vhostRouter.
|
||||
// createConn will get a connection from one proxy of the group
|
||||
groupRegistry[*HTTPGroup]
|
||||
vhostRouter *vhost.Routers
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewHTTPGroupController(vhostRouter *vhost.Routers) *HTTPGroupController {
|
||||
return &HTTPGroupController{
|
||||
groups: make(map[string]*HTTPGroup),
|
||||
vhostRouter: vhostRouter,
|
||||
groupRegistry: newGroupRegistry[*HTTPGroup](),
|
||||
vhostRouter: vhostRouter,
|
||||
}
|
||||
}
|
||||
|
||||
func (ctl *HTTPGroupController) Register(
|
||||
proxyName, group, groupKey string,
|
||||
routeConfig vhost.RouteConfig,
|
||||
) (err error) {
|
||||
indexKey := group
|
||||
ctl.mu.Lock()
|
||||
g, ok := ctl.groups[indexKey]
|
||||
if !ok {
|
||||
g = NewHTTPGroup(ctl)
|
||||
ctl.groups[indexKey] = g
|
||||
) error {
|
||||
for {
|
||||
g := ctl.getOrCreate(group, func() *HTTPGroup {
|
||||
return NewHTTPGroup(ctl)
|
||||
})
|
||||
err := g.Register(proxyName, group, groupKey, routeConfig)
|
||||
if err == errGroupStale {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
ctl.mu.Unlock()
|
||||
|
||||
return g.Register(proxyName, group, groupKey, routeConfig)
|
||||
}
|
||||
|
||||
func (ctl *HTTPGroupController) UnRegister(proxyName, group string, _ vhost.RouteConfig) {
|
||||
indexKey := group
|
||||
ctl.mu.Lock()
|
||||
defer ctl.mu.Unlock()
|
||||
g, ok := ctl.groups[indexKey]
|
||||
g, ok := ctl.get(group)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
isEmpty := g.UnRegister(proxyName)
|
||||
if isEmpty {
|
||||
delete(ctl.groups, indexKey)
|
||||
}
|
||||
g.UnRegister(proxyName)
|
||||
}
|
||||
|
||||
type HTTPGroup struct {
|
||||
@@ -87,6 +76,9 @@ func (g *HTTPGroup) Register(
|
||||
) (err error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if !g.ctl.isCurrent(group, func(cur *HTTPGroup) bool { return cur == g }) {
|
||||
return errGroupStale
|
||||
}
|
||||
if len(g.createFuncs) == 0 {
|
||||
// the first proxy in this group
|
||||
tmp := routeConfig // copy object
|
||||
@@ -123,7 +115,7 @@ func (g *HTTPGroup) Register(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *HTTPGroup) UnRegister(proxyName string) (isEmpty bool) {
|
||||
func (g *HTTPGroup) UnRegister(proxyName string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.createFuncs, proxyName)
|
||||
@@ -135,10 +127,11 @@ func (g *HTTPGroup) UnRegister(proxyName string) (isEmpty bool) {
|
||||
}
|
||||
|
||||
if len(g.createFuncs) == 0 {
|
||||
isEmpty = true
|
||||
g.ctl.vhostRouter.Del(g.domain, g.location, g.routeByHTTPUser)
|
||||
g.ctl.removeIf(g.group, func(cur *HTTPGroup) bool {
|
||||
return cur == g
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
|
||||
@@ -151,7 +144,7 @@ func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
|
||||
location := g.location
|
||||
routeByHTTPUser := g.routeByHTTPUser
|
||||
if len(g.pxyNames) > 0 {
|
||||
name := g.pxyNames[int(newIndex)%len(g.pxyNames)]
|
||||
name := g.pxyNames[newIndex%uint64(len(g.pxyNames))]
|
||||
f = g.createFuncs[name]
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
@@ -174,7 +167,7 @@ func (g *HTTPGroup) chooseEndpoint() (string, error) {
|
||||
location := g.location
|
||||
routeByHTTPUser := g.routeByHTTPUser
|
||||
if len(g.pxyNames) > 0 {
|
||||
name = g.pxyNames[int(newIndex)%len(g.pxyNames)]
|
||||
name = g.pxyNames[newIndex%uint64(len(g.pxyNames))]
|
||||
}
|
||||
g.mu.RUnlock()
|
||||
|
||||
|
||||
@@ -17,25 +17,19 @@ package group
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
gerr "github.com/fatedier/golib/errors"
|
||||
|
||||
"github.com/fatedier/frp/pkg/util/vhost"
|
||||
)
|
||||
|
||||
type HTTPSGroupController struct {
|
||||
groups map[string]*HTTPSGroup
|
||||
|
||||
groupRegistry[*HTTPSGroup]
|
||||
httpsMuxer *vhost.HTTPSMuxer
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewHTTPSGroupController(httpsMuxer *vhost.HTTPSMuxer) *HTTPSGroupController {
|
||||
return &HTTPSGroupController{
|
||||
groups: make(map[string]*HTTPSGroup),
|
||||
httpsMuxer: httpsMuxer,
|
||||
groupRegistry: newGroupRegistry[*HTTPSGroup](),
|
||||
httpsMuxer: httpsMuxer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,41 +38,28 @@ func (ctl *HTTPSGroupController) Listen(
|
||||
group, groupKey string,
|
||||
routeConfig vhost.RouteConfig,
|
||||
) (l net.Listener, err error) {
|
||||
indexKey := group
|
||||
ctl.mu.Lock()
|
||||
g, ok := ctl.groups[indexKey]
|
||||
if !ok {
|
||||
g = NewHTTPSGroup(ctl)
|
||||
ctl.groups[indexKey] = g
|
||||
for {
|
||||
g := ctl.getOrCreate(group, func() *HTTPSGroup {
|
||||
return NewHTTPSGroup(ctl)
|
||||
})
|
||||
l, err = g.Listen(ctx, group, groupKey, routeConfig)
|
||||
if err == errGroupStale {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
ctl.mu.Unlock()
|
||||
|
||||
return g.Listen(ctx, group, groupKey, routeConfig)
|
||||
}
|
||||
|
||||
func (ctl *HTTPSGroupController) RemoveGroup(group string) {
|
||||
ctl.mu.Lock()
|
||||
defer ctl.mu.Unlock()
|
||||
delete(ctl.groups, group)
|
||||
}
|
||||
|
||||
type HTTPSGroup struct {
|
||||
group string
|
||||
groupKey string
|
||||
domain string
|
||||
baseGroup
|
||||
|
||||
acceptCh chan net.Conn
|
||||
httpsLn *vhost.Listener
|
||||
lns []*HTTPSGroupListener
|
||||
ctl *HTTPSGroupController
|
||||
mu sync.Mutex
|
||||
domain string
|
||||
ctl *HTTPSGroupController
|
||||
}
|
||||
|
||||
func NewHTTPSGroup(ctl *HTTPSGroupController) *HTTPSGroup {
|
||||
return &HTTPSGroup{
|
||||
lns: make([]*HTTPSGroupListener, 0),
|
||||
ctl: ctl,
|
||||
acceptCh: make(chan net.Conn),
|
||||
ctl: ctl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,23 +67,27 @@ func (g *HTTPSGroup) Listen(
|
||||
ctx context.Context,
|
||||
group, groupKey string,
|
||||
routeConfig vhost.RouteConfig,
|
||||
) (ln *HTTPSGroupListener, err error) {
|
||||
) (ln *Listener, err error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if !g.ctl.isCurrent(group, func(cur *HTTPSGroup) bool { return cur == g }) {
|
||||
return nil, errGroupStale
|
||||
}
|
||||
if len(g.lns) == 0 {
|
||||
// the first listener, listen on the real address
|
||||
httpsLn, errRet := g.ctl.httpsMuxer.Listen(ctx, &routeConfig)
|
||||
if errRet != nil {
|
||||
return nil, errRet
|
||||
}
|
||||
ln = newHTTPSGroupListener(group, g, httpsLn.Addr())
|
||||
|
||||
g.group = group
|
||||
g.groupKey = groupKey
|
||||
g.domain = routeConfig.Domain
|
||||
g.httpsLn = httpsLn
|
||||
g.lns = append(g.lns, ln)
|
||||
go g.worker()
|
||||
g.initBase(group, groupKey, httpsLn, func() {
|
||||
g.ctl.removeIf(g.group, func(cur *HTTPSGroup) bool {
|
||||
return cur == g
|
||||
})
|
||||
})
|
||||
ln = g.newListener(httpsLn.Addr())
|
||||
go g.worker(httpsLn, g.acceptCh)
|
||||
} else {
|
||||
// route config in the same group must be equal
|
||||
if g.group != group || g.domain != routeConfig.Domain {
|
||||
@@ -111,87 +96,7 @@ func (g *HTTPSGroup) Listen(
|
||||
if g.groupKey != groupKey {
|
||||
return nil, ErrGroupAuthFailed
|
||||
}
|
||||
ln = newHTTPSGroupListener(group, g, g.lns[0].Addr())
|
||||
g.lns = append(g.lns, ln)
|
||||
ln = g.newListener(g.lns[0].Addr())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (g *HTTPSGroup) worker() {
|
||||
for {
|
||||
c, err := g.httpsLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gerr.PanicToError(func() {
|
||||
g.acceptCh <- c
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *HTTPSGroup) Accept() <-chan net.Conn {
|
||||
return g.acceptCh
|
||||
}
|
||||
|
||||
func (g *HTTPSGroup) CloseListener(ln *HTTPSGroupListener) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for i, tmpLn := range g.lns {
|
||||
if tmpLn == ln {
|
||||
g.lns = append(g.lns[:i], g.lns[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(g.lns) == 0 {
|
||||
close(g.acceptCh)
|
||||
if g.httpsLn != nil {
|
||||
g.httpsLn.Close()
|
||||
}
|
||||
g.ctl.RemoveGroup(g.group)
|
||||
}
|
||||
}
|
||||
|
||||
type HTTPSGroupListener struct {
|
||||
groupName string
|
||||
group *HTTPSGroup
|
||||
|
||||
addr net.Addr
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
func newHTTPSGroupListener(name string, group *HTTPSGroup, addr net.Addr) *HTTPSGroupListener {
|
||||
return &HTTPSGroupListener{
|
||||
groupName: name,
|
||||
group: group,
|
||||
addr: addr,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (ln *HTTPSGroupListener) Accept() (c net.Conn, err error) {
|
||||
var ok bool
|
||||
select {
|
||||
case <-ln.closeCh:
|
||||
return nil, ErrListenerClosed
|
||||
case c, ok = <-ln.group.Accept():
|
||||
if !ok {
|
||||
return nil, ErrListenerClosed
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ln *HTTPSGroupListener) Addr() net.Addr {
|
||||
return ln.addr
|
||||
}
|
||||
|
||||
func (ln *HTTPSGroupListener) Close() (err error) {
|
||||
close(ln.closeCh)
|
||||
|
||||
// remove self from HTTPSGroup
|
||||
ln.group.CloseListener(ln)
|
||||
return
|
||||
}
|
||||
|
||||
49
server/group/listener.go
Normal file
49
server/group/listener.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Listener is a per-proxy virtual listener that receives connections
|
||||
// from a shared group. It implements net.Listener.
|
||||
type Listener struct {
|
||||
acceptCh <-chan net.Conn
|
||||
addr net.Addr
|
||||
closeCh chan struct{}
|
||||
onClose func(*Listener)
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newListener(acceptCh <-chan net.Conn, addr net.Addr, onClose func(*Listener)) *Listener {
|
||||
return &Listener{
|
||||
acceptCh: acceptCh,
|
||||
addr: addr,
|
||||
closeCh: make(chan struct{}),
|
||||
onClose: onClose,
|
||||
}
|
||||
}
|
||||
|
||||
func (ln *Listener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case <-ln.closeCh:
|
||||
return nil, ErrListenerClosed
|
||||
case c, ok := <-ln.acceptCh:
|
||||
if !ok {
|
||||
return nil, ErrListenerClosed
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ln *Listener) Addr() net.Addr {
|
||||
return ln.addr
|
||||
}
|
||||
|
||||
func (ln *Listener) Close() error {
|
||||
ln.once.Do(func() {
|
||||
close(ln.closeCh)
|
||||
ln.onClose(ln)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
68
server/group/listener_test.go
Normal file
68
server/group/listener_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListener_Accept(t *testing.T) {
|
||||
acceptCh := make(chan net.Conn, 1)
|
||||
ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
|
||||
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
acceptCh <- c1
|
||||
got, err := ln.Accept()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c1, got)
|
||||
}
|
||||
|
||||
func TestListener_AcceptAfterChannelClose(t *testing.T) {
|
||||
acceptCh := make(chan net.Conn)
|
||||
ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
|
||||
|
||||
close(acceptCh)
|
||||
_, err := ln.Accept()
|
||||
assert.ErrorIs(t, err, ErrListenerClosed)
|
||||
}
|
||||
|
||||
func TestListener_AcceptAfterListenerClose(t *testing.T) {
|
||||
acceptCh := make(chan net.Conn) // open, not closed
|
||||
ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
|
||||
|
||||
ln.Close()
|
||||
_, err := ln.Accept()
|
||||
assert.ErrorIs(t, err, ErrListenerClosed)
|
||||
}
|
||||
|
||||
func TestListener_DoubleClose(t *testing.T) {
|
||||
closeCalls := 0
|
||||
ln := newListener(
|
||||
make(chan net.Conn),
|
||||
fakeAddr("127.0.0.1:1234"),
|
||||
func(*Listener) { closeCalls++ },
|
||||
)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
ln.Close()
|
||||
ln.Close()
|
||||
})
|
||||
assert.Equal(t, 1, closeCalls, "onClose should be called exactly once")
|
||||
}
|
||||
|
||||
func TestListener_Addr(t *testing.T) {
|
||||
addr := fakeAddr("10.0.0.1:5555")
|
||||
ln := newListener(make(chan net.Conn), addr, func(*Listener) {})
|
||||
assert.Equal(t, addr, ln.Addr())
|
||||
}
|
||||
|
||||
// fakeAddr implements net.Addr for testing.
|
||||
type fakeAddr string
|
||||
|
||||
func (a fakeAddr) Network() string { return "tcp" }
|
||||
func (a fakeAddr) String() string { return string(a) }
|
||||
59
server/group/registry.go
Normal file
59
server/group/registry.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// groupRegistry is a concurrent map of named groups with
|
||||
// automatic creation on first access.
|
||||
type groupRegistry[G any] struct {
|
||||
groups map[string]G
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newGroupRegistry[G any]() groupRegistry[G] {
|
||||
return groupRegistry[G]{
|
||||
groups: make(map[string]G),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *groupRegistry[G]) getOrCreate(key string, newFn func() G) G {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
g, ok := r.groups[key]
|
||||
if !ok {
|
||||
g = newFn()
|
||||
r.groups[key] = g
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (r *groupRegistry[G]) get(key string) (G, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
g, ok := r.groups[key]
|
||||
return g, ok
|
||||
}
|
||||
|
||||
// isCurrent returns true if key exists in the registry and matchFn
|
||||
// returns true for the stored value.
|
||||
func (r *groupRegistry[G]) isCurrent(key string, matchFn func(G) bool) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
g, ok := r.groups[key]
|
||||
return ok && matchFn(g)
|
||||
}
|
||||
|
||||
// removeIf atomically looks up the group for key, calls fn on it,
|
||||
// and removes the entry if fn returns true.
|
||||
func (r *groupRegistry[G]) removeIf(key string, fn func(G) bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
g, ok := r.groups[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if fn(g) {
|
||||
delete(r.groups, key)
|
||||
}
|
||||
}
|
||||
102
server/group/registry_test.go
Normal file
102
server/group/registry_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetOrCreate_New(t *testing.T) {
|
||||
r := newGroupRegistry[*int]()
|
||||
called := 0
|
||||
v := 42
|
||||
got := r.getOrCreate("k", func() *int { called++; return &v })
|
||||
assert.Equal(t, 1, called)
|
||||
assert.Equal(t, &v, got)
|
||||
}
|
||||
|
||||
func TestGetOrCreate_Existing(t *testing.T) {
|
||||
r := newGroupRegistry[*int]()
|
||||
v := 42
|
||||
r.getOrCreate("k", func() *int { return &v })
|
||||
|
||||
called := 0
|
||||
got := r.getOrCreate("k", func() *int { called++; return nil })
|
||||
assert.Equal(t, 0, called)
|
||||
assert.Equal(t, &v, got)
|
||||
}
|
||||
|
||||
func TestGet_ExistingAndMissing(t *testing.T) {
|
||||
r := newGroupRegistry[*int]()
|
||||
v := 1
|
||||
r.getOrCreate("k", func() *int { return &v })
|
||||
|
||||
got, ok := r.get("k")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, &v, got)
|
||||
|
||||
_, ok = r.get("missing")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestIsCurrent(t *testing.T) {
|
||||
r := newGroupRegistry[*int]()
|
||||
v1 := 1
|
||||
v2 := 2
|
||||
r.getOrCreate("k", func() *int { return &v1 })
|
||||
|
||||
assert.True(t, r.isCurrent("k", func(g *int) bool { return g == &v1 }))
|
||||
assert.False(t, r.isCurrent("k", func(g *int) bool { return g == &v2 }))
|
||||
assert.False(t, r.isCurrent("missing", func(g *int) bool { return true }))
|
||||
}
|
||||
|
||||
func TestRemoveIf(t *testing.T) {
|
||||
t.Run("removes when fn returns true", func(t *testing.T) {
|
||||
r := newGroupRegistry[*int]()
|
||||
v := 1
|
||||
r.getOrCreate("k", func() *int { return &v })
|
||||
r.removeIf("k", func(g *int) bool { return g == &v })
|
||||
_, ok := r.get("k")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("keeps when fn returns false", func(t *testing.T) {
|
||||
r := newGroupRegistry[*int]()
|
||||
v := 1
|
||||
r.getOrCreate("k", func() *int { return &v })
|
||||
r.removeIf("k", func(g *int) bool { return false })
|
||||
_, ok := r.get("k")
|
||||
assert.True(t, ok)
|
||||
})
|
||||
|
||||
t.Run("noop on missing key", func(t *testing.T) {
|
||||
r := newGroupRegistry[*int]()
|
||||
r.removeIf("missing", func(g *int) bool { return true }) // should not panic
|
||||
})
|
||||
}
|
||||
|
||||
func TestConcurrentGetOrCreateAndRemoveIf(t *testing.T) {
|
||||
r := newGroupRegistry[*int]()
|
||||
const n = 100
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n * 2)
|
||||
for i := range n {
|
||||
v := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r.getOrCreate("k", func() *int { return &v })
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r.removeIf("k", func(*int) bool { return true })
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// After all goroutines finish, accessing the key must not panic.
|
||||
require.NotPanics(t, func() {
|
||||
_, _ = r.get("k")
|
||||
})
|
||||
}
|
||||
@@ -17,83 +17,67 @@ package group
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
gerr "github.com/fatedier/golib/errors"
|
||||
|
||||
"github.com/fatedier/frp/server/ports"
|
||||
)
|
||||
|
||||
// TCPGroupCtl manage all TCPGroups
|
||||
// TCPGroupCtl manages all TCPGroups.
|
||||
type TCPGroupCtl struct {
|
||||
groups map[string]*TCPGroup
|
||||
|
||||
// portManager is used to manage port
|
||||
groupRegistry[*TCPGroup]
|
||||
portManager *ports.Manager
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewTCPGroupCtl return a new TcpGroupCtl
|
||||
// NewTCPGroupCtl returns a new TCPGroupCtl.
|
||||
func NewTCPGroupCtl(portManager *ports.Manager) *TCPGroupCtl {
|
||||
return &TCPGroupCtl{
|
||||
groups: make(map[string]*TCPGroup),
|
||||
portManager: portManager,
|
||||
groupRegistry: newGroupRegistry[*TCPGroup](),
|
||||
portManager: portManager,
|
||||
}
|
||||
}
|
||||
|
||||
// Listen is the wrapper for TCPGroup's Listen
|
||||
// If there are no group, we will create one here
|
||||
// Listen is the wrapper for TCPGroup's Listen.
|
||||
// If there is no group, one will be created.
|
||||
func (tgc *TCPGroupCtl) Listen(proxyName string, group string, groupKey string,
|
||||
addr string, port int,
|
||||
) (l net.Listener, realPort int, err error) {
|
||||
tgc.mu.Lock()
|
||||
tcpGroup, ok := tgc.groups[group]
|
||||
if !ok {
|
||||
tcpGroup = NewTCPGroup(tgc)
|
||||
tgc.groups[group] = tcpGroup
|
||||
for {
|
||||
tcpGroup := tgc.getOrCreate(group, func() *TCPGroup {
|
||||
return NewTCPGroup(tgc)
|
||||
})
|
||||
l, realPort, err = tcpGroup.Listen(proxyName, group, groupKey, addr, port)
|
||||
if err == errGroupStale {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
tgc.mu.Unlock()
|
||||
|
||||
return tcpGroup.Listen(proxyName, group, groupKey, addr, port)
|
||||
}
|
||||
|
||||
// RemoveGroup remove TCPGroup from controller
|
||||
func (tgc *TCPGroupCtl) RemoveGroup(group string) {
|
||||
tgc.mu.Lock()
|
||||
defer tgc.mu.Unlock()
|
||||
delete(tgc.groups, group)
|
||||
}
|
||||
|
||||
// TCPGroup route connections to different proxies
|
||||
// TCPGroup routes connections to different proxies.
|
||||
type TCPGroup struct {
|
||||
group string
|
||||
groupKey string
|
||||
baseGroup
|
||||
|
||||
addr string
|
||||
port int
|
||||
realPort int
|
||||
|
||||
acceptCh chan net.Conn
|
||||
tcpLn net.Listener
|
||||
lns []*TCPGroupListener
|
||||
ctl *TCPGroupCtl
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewTCPGroup return a new TCPGroup
|
||||
// NewTCPGroup returns a new TCPGroup.
|
||||
func NewTCPGroup(ctl *TCPGroupCtl) *TCPGroup {
|
||||
return &TCPGroup{
|
||||
lns: make([]*TCPGroupListener, 0),
|
||||
ctl: ctl,
|
||||
acceptCh: make(chan net.Conn),
|
||||
ctl: ctl,
|
||||
}
|
||||
}
|
||||
|
||||
// Listen will return a new TCPGroupListener
|
||||
// if TCPGroup already has a listener, just add a new TCPGroupListener to the queues
|
||||
// otherwise, listen on the real address
|
||||
func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr string, port int) (ln *TCPGroupListener, realPort int, err error) {
|
||||
// Listen will return a new Listener.
|
||||
// If TCPGroup already has a listener, just add a new Listener to the queues,
|
||||
// otherwise listen on the real address.
|
||||
func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr string, port int) (ln *Listener, realPort int, err error) {
|
||||
tg.mu.Lock()
|
||||
defer tg.mu.Unlock()
|
||||
if !tg.ctl.isCurrent(group, func(cur *TCPGroup) bool { return cur == tg }) {
|
||||
return nil, 0, errGroupStale
|
||||
}
|
||||
if len(tg.lns) == 0 {
|
||||
// the first listener, listen on the real address
|
||||
realPort, err = tg.ctl.portManager.Acquire(proxyName, port)
|
||||
@@ -106,19 +90,18 @@ func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr
|
||||
err = errRet
|
||||
return
|
||||
}
|
||||
ln = newTCPGroupListener(group, tg, tcpLn.Addr())
|
||||
|
||||
tg.group = group
|
||||
tg.groupKey = groupKey
|
||||
tg.addr = addr
|
||||
tg.port = port
|
||||
tg.realPort = realPort
|
||||
tg.tcpLn = tcpLn
|
||||
tg.lns = append(tg.lns, ln)
|
||||
if tg.acceptCh == nil {
|
||||
tg.acceptCh = make(chan net.Conn)
|
||||
}
|
||||
go tg.worker()
|
||||
tg.initBase(group, groupKey, tcpLn, func() {
|
||||
tg.ctl.portManager.Release(tg.realPort)
|
||||
tg.ctl.removeIf(tg.group, func(cur *TCPGroup) bool {
|
||||
return cur == tg
|
||||
})
|
||||
})
|
||||
ln = tg.newListener(tcpLn.Addr())
|
||||
go tg.worker(tcpLn, tg.acceptCh)
|
||||
} else {
|
||||
// address and port in the same group must be equal
|
||||
if tg.group != group || tg.addr != addr {
|
||||
@@ -133,92 +116,8 @@ func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr
|
||||
err = ErrGroupAuthFailed
|
||||
return
|
||||
}
|
||||
ln = newTCPGroupListener(group, tg, tg.lns[0].Addr())
|
||||
ln = tg.newListener(tg.lns[0].Addr())
|
||||
realPort = tg.realPort
|
||||
tg.lns = append(tg.lns, ln)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// worker is called when the real tcp listener has been created
|
||||
func (tg *TCPGroup) worker() {
|
||||
for {
|
||||
c, err := tg.tcpLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gerr.PanicToError(func() {
|
||||
tg.acceptCh <- c
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tg *TCPGroup) Accept() <-chan net.Conn {
|
||||
return tg.acceptCh
|
||||
}
|
||||
|
||||
// CloseListener remove the TCPGroupListener from the TCPGroup
|
||||
func (tg *TCPGroup) CloseListener(ln *TCPGroupListener) {
|
||||
tg.mu.Lock()
|
||||
defer tg.mu.Unlock()
|
||||
for i, tmpLn := range tg.lns {
|
||||
if tmpLn == ln {
|
||||
tg.lns = append(tg.lns[:i], tg.lns[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(tg.lns) == 0 {
|
||||
close(tg.acceptCh)
|
||||
tg.tcpLn.Close()
|
||||
tg.ctl.portManager.Release(tg.realPort)
|
||||
tg.ctl.RemoveGroup(tg.group)
|
||||
}
|
||||
}
|
||||
|
||||
// TCPGroupListener
|
||||
type TCPGroupListener struct {
|
||||
groupName string
|
||||
group *TCPGroup
|
||||
|
||||
addr net.Addr
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
func newTCPGroupListener(name string, group *TCPGroup, addr net.Addr) *TCPGroupListener {
|
||||
return &TCPGroupListener{
|
||||
groupName: name,
|
||||
group: group,
|
||||
addr: addr,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Accept will accept connections from TCPGroup
|
||||
func (ln *TCPGroupListener) Accept() (c net.Conn, err error) {
|
||||
var ok bool
|
||||
select {
|
||||
case <-ln.closeCh:
|
||||
return nil, ErrListenerClosed
|
||||
case c, ok = <-ln.group.Accept():
|
||||
if !ok {
|
||||
return nil, ErrListenerClosed
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ln *TCPGroupListener) Addr() net.Addr {
|
||||
return ln.addr
|
||||
}
|
||||
|
||||
// Close close the listener
|
||||
func (ln *TCPGroupListener) Close() (err error) {
|
||||
close(ln.closeCh)
|
||||
|
||||
// remove self from TcpGroup
|
||||
ln.group.CloseListener(ln)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,118 +18,100 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
gerr "github.com/fatedier/golib/errors"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/util/tcpmux"
|
||||
"github.com/fatedier/frp/pkg/util/vhost"
|
||||
)
|
||||
|
||||
// TCPMuxGroupCtl manage all TCPMuxGroups
|
||||
// TCPMuxGroupCtl manages all TCPMuxGroups.
|
||||
type TCPMuxGroupCtl struct {
|
||||
groups map[string]*TCPMuxGroup
|
||||
|
||||
// portManager is used to manage port
|
||||
groupRegistry[*TCPMuxGroup]
|
||||
tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewTCPMuxGroupCtl return a new TCPMuxGroupCtl
|
||||
// NewTCPMuxGroupCtl returns a new TCPMuxGroupCtl.
|
||||
func NewTCPMuxGroupCtl(tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer) *TCPMuxGroupCtl {
|
||||
return &TCPMuxGroupCtl{
|
||||
groups: make(map[string]*TCPMuxGroup),
|
||||
groupRegistry: newGroupRegistry[*TCPMuxGroup](),
|
||||
tcpMuxHTTPConnectMuxer: tcpMuxHTTPConnectMuxer,
|
||||
}
|
||||
}
|
||||
|
||||
// Listen is the wrapper for TCPMuxGroup's Listen
|
||||
// If there are no group, we will create one here
|
||||
// Listen is the wrapper for TCPMuxGroup's Listen.
|
||||
// If there is no group, one will be created.
|
||||
func (tmgc *TCPMuxGroupCtl) Listen(
|
||||
ctx context.Context,
|
||||
multiplexer, group, groupKey string,
|
||||
routeConfig vhost.RouteConfig,
|
||||
) (l net.Listener, err error) {
|
||||
tmgc.mu.Lock()
|
||||
tcpMuxGroup, ok := tmgc.groups[group]
|
||||
if !ok {
|
||||
tcpMuxGroup = NewTCPMuxGroup(tmgc)
|
||||
tmgc.groups[group] = tcpMuxGroup
|
||||
}
|
||||
tmgc.mu.Unlock()
|
||||
for {
|
||||
tcpMuxGroup := tmgc.getOrCreate(group, func() *TCPMuxGroup {
|
||||
return NewTCPMuxGroup(tmgc)
|
||||
})
|
||||
|
||||
switch v1.TCPMultiplexerType(multiplexer) {
|
||||
case v1.TCPMultiplexerHTTPConnect:
|
||||
return tcpMuxGroup.HTTPConnectListen(ctx, group, groupKey, routeConfig)
|
||||
default:
|
||||
err = fmt.Errorf("unknown multiplexer [%s]", multiplexer)
|
||||
return
|
||||
switch v1.TCPMultiplexerType(multiplexer) {
|
||||
case v1.TCPMultiplexerHTTPConnect:
|
||||
l, err = tcpMuxGroup.HTTPConnectListen(ctx, group, groupKey, routeConfig)
|
||||
if err == errGroupStale {
|
||||
continue
|
||||
}
|
||||
return
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown multiplexer [%s]", multiplexer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveGroup remove TCPMuxGroup from controller
|
||||
func (tmgc *TCPMuxGroupCtl) RemoveGroup(group string) {
|
||||
tmgc.mu.Lock()
|
||||
defer tmgc.mu.Unlock()
|
||||
delete(tmgc.groups, group)
|
||||
}
|
||||
|
||||
// TCPMuxGroup route connections to different proxies
|
||||
// TCPMuxGroup routes connections to different proxies.
|
||||
type TCPMuxGroup struct {
|
||||
group string
|
||||
groupKey string
|
||||
baseGroup
|
||||
|
||||
domain string
|
||||
routeByHTTPUser string
|
||||
username string
|
||||
password string
|
||||
|
||||
acceptCh chan net.Conn
|
||||
tcpMuxLn net.Listener
|
||||
lns []*TCPMuxGroupListener
|
||||
ctl *TCPMuxGroupCtl
|
||||
mu sync.Mutex
|
||||
ctl *TCPMuxGroupCtl
|
||||
}
|
||||
|
||||
// NewTCPMuxGroup return a new TCPMuxGroup
|
||||
// NewTCPMuxGroup returns a new TCPMuxGroup.
|
||||
func NewTCPMuxGroup(ctl *TCPMuxGroupCtl) *TCPMuxGroup {
|
||||
return &TCPMuxGroup{
|
||||
lns: make([]*TCPMuxGroupListener, 0),
|
||||
ctl: ctl,
|
||||
acceptCh: make(chan net.Conn),
|
||||
ctl: ctl,
|
||||
}
|
||||
}
|
||||
|
||||
// Listen will return a new TCPMuxGroupListener
|
||||
// if TCPMuxGroup already has a listener, just add a new TCPMuxGroupListener to the queues
|
||||
// otherwise, listen on the real address
|
||||
// HTTPConnectListen will return a new Listener.
|
||||
// If TCPMuxGroup already has a listener, just add a new Listener to the queues,
|
||||
// otherwise listen on the real address.
|
||||
func (tmg *TCPMuxGroup) HTTPConnectListen(
|
||||
ctx context.Context,
|
||||
group, groupKey string,
|
||||
routeConfig vhost.RouteConfig,
|
||||
) (ln *TCPMuxGroupListener, err error) {
|
||||
) (ln *Listener, err error) {
|
||||
tmg.mu.Lock()
|
||||
defer tmg.mu.Unlock()
|
||||
if !tmg.ctl.isCurrent(group, func(cur *TCPMuxGroup) bool { return cur == tmg }) {
|
||||
return nil, errGroupStale
|
||||
}
|
||||
if len(tmg.lns) == 0 {
|
||||
// the first listener, listen on the real address
|
||||
tcpMuxLn, errRet := tmg.ctl.tcpMuxHTTPConnectMuxer.Listen(ctx, &routeConfig)
|
||||
if errRet != nil {
|
||||
return nil, errRet
|
||||
}
|
||||
ln = newTCPMuxGroupListener(group, tmg, tcpMuxLn.Addr())
|
||||
|
||||
tmg.group = group
|
||||
tmg.groupKey = groupKey
|
||||
tmg.domain = routeConfig.Domain
|
||||
tmg.routeByHTTPUser = routeConfig.RouteByHTTPUser
|
||||
tmg.username = routeConfig.Username
|
||||
tmg.password = routeConfig.Password
|
||||
tmg.tcpMuxLn = tcpMuxLn
|
||||
tmg.lns = append(tmg.lns, ln)
|
||||
if tmg.acceptCh == nil {
|
||||
tmg.acceptCh = make(chan net.Conn)
|
||||
}
|
||||
go tmg.worker()
|
||||
tmg.initBase(group, groupKey, tcpMuxLn, func() {
|
||||
tmg.ctl.removeIf(tmg.group, func(cur *TCPMuxGroup) bool {
|
||||
return cur == tmg
|
||||
})
|
||||
})
|
||||
ln = tmg.newListener(tcpMuxLn.Addr())
|
||||
go tmg.worker(tcpMuxLn, tmg.acceptCh)
|
||||
} else {
|
||||
// route config in the same group must be equal
|
||||
if tmg.group != group || tmg.domain != routeConfig.Domain ||
|
||||
@@ -141,90 +123,7 @@ func (tmg *TCPMuxGroup) HTTPConnectListen(
|
||||
if tmg.groupKey != groupKey {
|
||||
return nil, ErrGroupAuthFailed
|
||||
}
|
||||
ln = newTCPMuxGroupListener(group, tmg, tmg.lns[0].Addr())
|
||||
tmg.lns = append(tmg.lns, ln)
|
||||
ln = tmg.newListener(tmg.lns[0].Addr())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// worker is called when the real TCP listener has been created
|
||||
func (tmg *TCPMuxGroup) worker() {
|
||||
for {
|
||||
c, err := tmg.tcpMuxLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = gerr.PanicToError(func() {
|
||||
tmg.acceptCh <- c
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tmg *TCPMuxGroup) Accept() <-chan net.Conn {
|
||||
return tmg.acceptCh
|
||||
}
|
||||
|
||||
// CloseListener remove the TCPMuxGroupListener from the TCPMuxGroup
|
||||
func (tmg *TCPMuxGroup) CloseListener(ln *TCPMuxGroupListener) {
|
||||
tmg.mu.Lock()
|
||||
defer tmg.mu.Unlock()
|
||||
for i, tmpLn := range tmg.lns {
|
||||
if tmpLn == ln {
|
||||
tmg.lns = append(tmg.lns[:i], tmg.lns[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(tmg.lns) == 0 {
|
||||
close(tmg.acceptCh)
|
||||
tmg.tcpMuxLn.Close()
|
||||
tmg.ctl.RemoveGroup(tmg.group)
|
||||
}
|
||||
}
|
||||
|
||||
// TCPMuxGroupListener
|
||||
type TCPMuxGroupListener struct {
|
||||
groupName string
|
||||
group *TCPMuxGroup
|
||||
|
||||
addr net.Addr
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
func newTCPMuxGroupListener(name string, group *TCPMuxGroup, addr net.Addr) *TCPMuxGroupListener {
|
||||
return &TCPMuxGroupListener{
|
||||
groupName: name,
|
||||
group: group,
|
||||
addr: addr,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Accept will accept connections from TCPMuxGroup
|
||||
func (ln *TCPMuxGroupListener) Accept() (c net.Conn, err error) {
|
||||
var ok bool
|
||||
select {
|
||||
case <-ln.closeCh:
|
||||
return nil, ErrListenerClosed
|
||||
case c, ok = <-ln.group.Accept():
|
||||
if !ok {
|
||||
return nil, ErrListenerClosed
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ln *TCPMuxGroupListener) Addr() net.Addr {
|
||||
return ln.addr
|
||||
}
|
||||
|
||||
// Close close the listener
|
||||
func (ln *TCPMuxGroupListener) Close() (err error) {
|
||||
close(ln.closeCh)
|
||||
|
||||
// remove self from TcpMuxGroup
|
||||
ln.group.CloseListener(ln)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ var _ = ginkgo.Describe("[Feature: Example]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
|
||||
@@ -3,68 +3,84 @@ package framework
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config"
|
||||
flog "github.com/fatedier/frp/pkg/util/log"
|
||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/process"
|
||||
)
|
||||
|
||||
// RunProcesses run multiple processes from templates.
|
||||
// The first template should always be frps.
|
||||
func (f *Framework) RunProcesses(serverTemplates []string, clientTemplates []string) ([]*process.Process, []*process.Process) {
|
||||
templates := make([]string, 0, len(serverTemplates)+len(clientTemplates))
|
||||
templates = append(templates, serverTemplates...)
|
||||
templates = append(templates, clientTemplates...)
|
||||
// RunProcesses starts one frps and zero or more frpc processes from templates.
|
||||
func (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string) (*process.Process, []*process.Process) {
|
||||
templates := append([]string{serverTemplate}, clientTemplates...)
|
||||
outs, ports, err := f.RenderTemplates(templates)
|
||||
ExpectNoError(err)
|
||||
ExpectTrue(len(templates) > 0)
|
||||
|
||||
maps.Copy(f.usedPorts, ports)
|
||||
|
||||
currentServerProcesses := make([]*process.Process, 0, len(serverTemplates))
|
||||
for i := range serverTemplates {
|
||||
path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-server-%d", i))
|
||||
err = os.WriteFile(path, []byte(outs[i]), 0o600)
|
||||
ExpectNoError(err)
|
||||
// Start frps.
|
||||
serverPath := filepath.Join(f.TempDirectory, "frp-e2e-server-0")
|
||||
err = os.WriteFile(serverPath, []byte(outs[0]), 0o600)
|
||||
ExpectNoError(err)
|
||||
|
||||
if TestContext.Debug {
|
||||
flog.Debugf("[%s] %s", path, outs[i])
|
||||
}
|
||||
|
||||
p := process.NewWithEnvs(TestContext.FRPServerPath, []string{"-c", path}, f.osEnvs)
|
||||
f.serverConfPaths = append(f.serverConfPaths, path)
|
||||
f.serverProcesses = append(f.serverProcesses, p)
|
||||
currentServerProcesses = append(currentServerProcesses, p)
|
||||
err = p.Start()
|
||||
ExpectNoError(err)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if TestContext.Debug {
|
||||
flog.Debugf("[%s] %s", serverPath, outs[0])
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
currentClientProcesses := make([]*process.Process, 0, len(clientTemplates))
|
||||
serverProcess := process.NewWithEnvs(TestContext.FRPServerPath, []string{"-c", serverPath}, f.osEnvs)
|
||||
f.serverConfPaths = append(f.serverConfPaths, serverPath)
|
||||
f.serverProcesses = append(f.serverProcesses, serverProcess)
|
||||
err = serverProcess.Start()
|
||||
ExpectNoError(err)
|
||||
|
||||
if port, ok := ports[consts.PortServerName]; ok {
|
||||
ExpectNoError(WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 5*time.Second))
|
||||
} else {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
// Start frpc(s).
|
||||
clientProcesses := make([]*process.Process, 0, len(clientTemplates))
|
||||
for i := range clientTemplates {
|
||||
index := i + len(serverTemplates)
|
||||
path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-client-%d", i))
|
||||
err = os.WriteFile(path, []byte(outs[index]), 0o600)
|
||||
err = os.WriteFile(path, []byte(outs[1+i]), 0o600)
|
||||
ExpectNoError(err)
|
||||
|
||||
if TestContext.Debug {
|
||||
flog.Debugf("[%s] %s", path, outs[index])
|
||||
flog.Debugf("[%s] %s", path, outs[1+i])
|
||||
}
|
||||
|
||||
p := process.NewWithEnvs(TestContext.FRPClientPath, []string{"-c", path}, f.osEnvs)
|
||||
f.clientConfPaths = append(f.clientConfPaths, path)
|
||||
f.clientProcesses = append(f.clientProcesses, p)
|
||||
currentClientProcesses = append(currentClientProcesses, p)
|
||||
clientProcesses = append(clientProcesses, p)
|
||||
err = p.Start()
|
||||
ExpectNoError(err)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
// Wait for each client's proxies to register with frps.
|
||||
// If any client has no proxies (e.g. visitor-only), fall back to sleep
|
||||
// for the remaining time since visitors have no deterministic readiness signal.
|
||||
allConfirmed := len(clientProcesses) > 0
|
||||
start := time.Now()
|
||||
for i, p := range clientProcesses {
|
||||
configPath := f.clientConfPaths[len(f.clientConfPaths)-len(clientProcesses)+i]
|
||||
if !waitForClientProxyReady(configPath, p, 5*time.Second) {
|
||||
allConfirmed = false
|
||||
}
|
||||
}
|
||||
if len(clientProcesses) > 0 && !allConfirmed {
|
||||
remaining := 1500*time.Millisecond - time.Since(start)
|
||||
if remaining > 0 {
|
||||
time.Sleep(remaining)
|
||||
}
|
||||
}
|
||||
|
||||
return currentServerProcesses, currentClientProcesses
|
||||
return serverProcess, clientProcesses
|
||||
}
|
||||
|
||||
func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
|
||||
@@ -72,11 +88,13 @@ func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
|
||||
f.serverProcesses = append(f.serverProcesses, p)
|
||||
err := p.Start()
|
||||
if err != nil {
|
||||
return p, p.StdOutput(), err
|
||||
return p, p.Output(), err
|
||||
}
|
||||
// Give frps extra time to finish binding ports before proceeding.
|
||||
time.Sleep(4 * time.Second)
|
||||
return p, p.StdOutput(), nil
|
||||
select {
|
||||
case <-p.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
return p, p.Output(), nil
|
||||
}
|
||||
|
||||
func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
|
||||
@@ -84,10 +102,13 @@ func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
|
||||
f.clientProcesses = append(f.clientProcesses, p)
|
||||
err := p.Start()
|
||||
if err != nil {
|
||||
return p, p.StdOutput(), err
|
||||
return p, p.Output(), err
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
return p, p.StdOutput(), nil
|
||||
select {
|
||||
case <-p.Done():
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
return p, p.Output(), nil
|
||||
}
|
||||
|
||||
func (f *Framework) GenerateConfigFile(content string) string {
|
||||
@@ -97,3 +118,74 @@ func (f *Framework) GenerateConfigFile(content string) string {
|
||||
ExpectNoError(err)
|
||||
return path
|
||||
}
|
||||
|
||||
// waitForClientProxyReady parses the client config to extract proxy names,
|
||||
// then waits for each proxy's "start proxy success" log in the process output.
|
||||
// Returns true only if proxies were expected and all registered successfully.
|
||||
func waitForClientProxyReady(configPath string, p *process.Process, timeout time.Duration) bool {
|
||||
_, proxyCfgs, _, _, err := config.LoadClientConfig(configPath, false)
|
||||
if err != nil || len(proxyCfgs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use a single deadline so the total wait across all proxies does not exceed timeout.
|
||||
deadline := time.Now().Add(timeout)
|
||||
for _, cfg := range proxyCfgs {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return false
|
||||
}
|
||||
name := cfg.GetBaseConfig().Name
|
||||
pattern := fmt.Sprintf("[%s] start proxy success", name)
|
||||
if err := p.WaitForOutput(pattern, 1, remaining); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// WaitForTCPUnreachable polls a TCP address until a connection fails or timeout.
|
||||
func WaitForTCPUnreachable(addr string, interval, timeout time.Duration) error {
|
||||
if interval <= 0 {
|
||||
return fmt.Errorf("invalid interval for TCP unreachable on %s: interval must be positive", addr)
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("invalid timeout for TCP unreachable on %s: timeout must be positive", addr)
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return fmt.Errorf("timeout waiting for TCP unreachable on %s", addr)
|
||||
}
|
||||
dialTimeout := min(interval, remaining)
|
||||
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
conn.Close()
|
||||
time.Sleep(min(interval, time.Until(deadline)))
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForTCPReady polls a TCP address until a connection succeeds or timeout.
|
||||
func WaitForTCPReady(addr string, timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("invalid timeout for TCP readiness on %s: timeout must be positive", addr)
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if lastErr == nil {
|
||||
return fmt.Errorf("timeout waiting for TCP readiness on %s before any dial attempt", addr)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for TCP readiness on %s: %w", addr, lastErr)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
for _, test := range tests {
|
||||
framework.NewRequestExpect(f).
|
||||
@@ -152,7 +152,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
for _, test := range tests {
|
||||
for domain := range strings.SplitSeq(test.customDomains, ",") {
|
||||
@@ -235,7 +235,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
|
||||
framework.ExpectNoError(err)
|
||||
@@ -419,7 +419,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
}
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
|
||||
|
||||
for _, test := range tests {
|
||||
timeout := time.Second
|
||||
@@ -497,7 +497,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
}
|
||||
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
// Request without HTTP connect should get error
|
||||
framework.NewRequestExpect(f).
|
||||
|
||||
@@ -48,7 +48,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
|
||||
framework.TCPEchoServerPort, p2Port,
|
||||
framework.TCPEchoServerPort, p3Port)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(p1Port).Ensure()
|
||||
framework.NewRequestExpect(f).Port(p2Port).Ensure()
|
||||
@@ -90,7 +90,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
|
||||
admin_pwd = admin
|
||||
`, dashboardPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPPath("/healthz")
|
||||
@@ -116,7 +116,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
|
||||
remote_port = %d
|
||||
`, adminPort, framework.TCPEchoServerPort, testPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(testPort).Ensure()
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
|
||||
clientConfs = append(clientConfs, client2Conf)
|
||||
}
|
||||
|
||||
f.RunProcesses([]string{serverConf}, clientConfs)
|
||||
f.RunProcesses(serverConf, clientConfs)
|
||||
|
||||
if configures.testDelay > 0 {
|
||||
time.Sleep(configures.testDelay)
|
||||
|
||||
@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
|
||||
`, "`", "`", framework.TCPEchoServerPort, portName)
|
||||
|
||||
f.SetEnvs([]string{"FRP_TOKEN=123"})
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
locations = /bar
|
||||
`, fooPort, barPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
@@ -111,7 +111,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
custom_domains = normal.example.com
|
||||
`, fooPort, barPort, otherPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// user1
|
||||
framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort).
|
||||
@@ -152,7 +152,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
http_pwd = test
|
||||
`, framework.HTTPSimpleServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// not set auth header
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
@@ -188,7 +188,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
custom_domains = *.example.com
|
||||
`, framework.HTTPSimpleServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// not match host
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
@@ -238,7 +238,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
subdomain = bar
|
||||
`, fooPort, barPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// foo
|
||||
framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort).
|
||||
@@ -279,7 +279,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
header_X-From-Where = frp
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// not set auth header
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
@@ -312,7 +312,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
host_header_rewrite = rewrite.example.com
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
@@ -360,7 +360,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
custom_domains = 127.0.0.1
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)}
|
||||
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
|
||||
@@ -58,7 +58,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
||||
remote_port = 11003
|
||||
`, framework.UDPEchoServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// TCP
|
||||
// Allowed in range
|
||||
@@ -97,7 +97,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
||||
local_port = {{ .%s }}
|
||||
`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
client := f.APIClientForFrpc(adminPort)
|
||||
|
||||
@@ -138,7 +138,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
||||
custom_domains = example.com
|
||||
`, framework.HTTPSimpleServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPHost("example.com")
|
||||
@@ -165,7 +165,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
||||
custom_domains = example.com
|
||||
`, framework.HTTPSimpleServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPPath("/healthz")
|
||||
|
||||
@@ -76,7 +76,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
|
||||
custom_domains = normal.example.com
|
||||
`, fooPort, barPort, otherPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// user1
|
||||
framework.NewRequestExpect(f).Explain("user1").
|
||||
@@ -121,7 +121,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
|
||||
http_pwd = test
|
||||
`, fooPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// not set auth header
|
||||
framework.NewRequestExpect(f).Explain("no auth").
|
||||
@@ -204,7 +204,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
|
||||
custom_domains = normal.example.com
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).
|
||||
RequestModify(func(r *request.Request) {
|
||||
|
||||
@@ -41,7 +41,7 @@ var _ = ginkgo.Describe("[Feature: XTCP]", func() {
|
||||
fallback_timeout_ms = 200
|
||||
`, framework.TCPEchoServerPort, bindPortName)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.NewRequestExpect(f).
|
||||
RequestModify(func(r *request.Request) {
|
||||
r.Timeout(time.Second)
|
||||
|
||||
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
|
||||
bandwidth_limit = 10KB
|
||||
`, localPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
content := strings.Repeat("a", 50*1024) // 5KB
|
||||
start := time.Now()
|
||||
@@ -89,7 +89,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
|
||||
remote_port = %d
|
||||
`, localPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
content := strings.Repeat("a", 50*1024) // 5KB
|
||||
start := time.Now()
|
||||
|
||||
@@ -88,7 +88,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
group_key = 123
|
||||
`, fooPort, remotePort, barPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
fooCount := 0
|
||||
barCount := 0
|
||||
@@ -144,7 +144,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
health_check_interval_s = 1
|
||||
`, fooPort, remotePort, barPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// check foo and bar is ok
|
||||
results := []string{}
|
||||
@@ -213,7 +213,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
health_check_url = /healthz
|
||||
`, fooPort, barPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// send first HTTP request
|
||||
var contents []string
|
||||
|
||||
@@ -38,7 +38,7 @@ var _ = ginkgo.Describe("[Feature: Heartbeat]", func() {
|
||||
`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)
|
||||
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure()
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Monitor]", func() {
|
||||
remote_port = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
@@ -44,7 +44,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
custom_domains = normal.example.com
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
@@ -90,7 +90,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
proxy_protocol_version = v2
|
||||
`, localPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
|
||||
log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
|
||||
@@ -136,7 +136,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
proxy_protocol_version = v2
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPHost("normal.example.com")
|
||||
|
||||
@@ -70,7 +70,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
for _, test := range tests {
|
||||
framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()
|
||||
@@ -92,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
plugin_http_passwd = 123
|
||||
`, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// http proxy, no auth info
|
||||
framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {
|
||||
@@ -124,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
plugin_passwd = 123
|
||||
`, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// http proxy, no auth info
|
||||
framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {
|
||||
@@ -168,7 +168,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
plugin_http_passwd = 123
|
||||
`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// from tcp proxy
|
||||
framework.NewRequestExpect(f).Request(
|
||||
@@ -202,7 +202,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
plugin_local_addr = 127.0.0.1:%d
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
|
||||
framework.ExpectNoError(err)
|
||||
@@ -246,7 +246,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
plugin_key_path = %s
|
||||
`, localPort, crtPath, keyPath)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
localServer := httpserver.New(
|
||||
httpserver.WithBindPort(localPort),
|
||||
@@ -290,7 +290,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
plugin_key_path = %s
|
||||
`, localPort, crtPath, keyPath)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
|
||||
framework.ExpectNoError(err)
|
||||
|
||||
@@ -71,7 +71,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remote_port = %d
|
||||
`, framework.TCPEchoServerPort, remotePort2)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf, invalidTokenClientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()
|
||||
@@ -119,7 +119,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remote_port = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
@@ -153,7 +153,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remote_port = 0
|
||||
`, framework.TCPEchoServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
@@ -195,7 +195,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remote_port = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
_, clients := f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
_, clients := f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
@@ -250,7 +250,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remote_port = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
@@ -297,7 +297,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remote_port = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
@@ -342,7 +342,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remote_port = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
@@ -389,7 +389,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remote_port = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
|
||||
258
test/e2e/mock/server/oidcserver/oidcserver.go
Normal file
258
test/e2e/mock/server/oidcserver/oidcserver.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// 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 oidcserver provides a minimal mock OIDC server for e2e testing.
|
||||
// It implements three endpoints:
|
||||
// - /.well-known/openid-configuration (discovery)
|
||||
// - /jwks (JSON Web Key Set)
|
||||
// - /token (client_credentials grant)
|
||||
package oidcserver
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
bindAddr string
|
||||
bindPort int
|
||||
l net.Listener
|
||||
hs *http.Server
|
||||
|
||||
privateKey *rsa.PrivateKey
|
||||
kid string
|
||||
|
||||
clientID string
|
||||
clientSecret string
|
||||
audience string
|
||||
subject string
|
||||
expiresIn int // seconds; 0 means omit expires_in from token response
|
||||
|
||||
tokenRequestCount atomic.Int64
|
||||
}
|
||||
|
||||
type Option func(*Server)
|
||||
|
||||
func WithBindPort(port int) Option {
|
||||
return func(s *Server) { s.bindPort = port }
|
||||
}
|
||||
|
||||
func WithClientCredentials(id, secret string) Option {
|
||||
return func(s *Server) {
|
||||
s.clientID = id
|
||||
s.clientSecret = secret
|
||||
}
|
||||
}
|
||||
|
||||
func WithAudience(aud string) Option {
|
||||
return func(s *Server) { s.audience = aud }
|
||||
}
|
||||
|
||||
func WithSubject(sub string) Option {
|
||||
return func(s *Server) { s.subject = sub }
|
||||
}
|
||||
|
||||
func WithExpiresIn(seconds int) Option {
|
||||
return func(s *Server) { s.expiresIn = seconds }
|
||||
}
|
||||
|
||||
func New(options ...Option) *Server {
|
||||
s := &Server{
|
||||
bindAddr: "127.0.0.1",
|
||||
kid: "test-key-1",
|
||||
clientID: "test-client",
|
||||
clientSecret: "test-secret",
|
||||
audience: "frps",
|
||||
subject: "test-service",
|
||||
expiresIn: 3600,
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Run() error {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate RSA key: %w", err)
|
||||
}
|
||||
s.privateKey = key
|
||||
|
||||
s.l, err = net.Listen("tcp", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.bindPort = s.l.Addr().(*net.TCPAddr).Port
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery)
|
||||
mux.HandleFunc("/jwks", s.handleJWKS)
|
||||
mux.HandleFunc("/token", s.handleToken)
|
||||
|
||||
s.hs = &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: time.Minute,
|
||||
}
|
||||
go func() { _ = s.hs.Serve(s.l) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
if s.hs != nil {
|
||||
return s.hs.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) BindAddr() string { return s.bindAddr }
|
||||
func (s *Server) BindPort() int { return s.bindPort }
|
||||
|
||||
func (s *Server) Issuer() string {
|
||||
return fmt.Sprintf("http://%s:%d", s.bindAddr, s.bindPort)
|
||||
}
|
||||
|
||||
func (s *Server) TokenEndpoint() string {
|
||||
return s.Issuer() + "/token"
|
||||
}
|
||||
|
||||
// TokenRequestCount returns the number of successful token requests served.
|
||||
func (s *Server) TokenRequestCount() int64 {
|
||||
return s.tokenRequestCount.Load()
|
||||
}
|
||||
|
||||
func (s *Server) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
|
||||
issuer := s.Issuer()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": issuer,
|
||||
"token_endpoint": issuer + "/token",
|
||||
"jwks_uri": issuer + "/jwks",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleJWKS(w http.ResponseWriter, _ *http.Request) {
|
||||
pub := &s.privateKey.PublicKey
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"keys": []map[string]any{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"kid": s.kid,
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if r.FormValue("grant_type") != "client_credentials" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "unsupported_grant_type",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Accept credentials from Basic Auth or form body.
|
||||
clientID, clientSecret, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
clientID = r.FormValue("client_id")
|
||||
clientSecret = r.FormValue("client_secret")
|
||||
}
|
||||
if clientID != s.clientID || clientSecret != s.clientSecret {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.signJWT()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
if s.expiresIn > 0 {
|
||||
resp["expires_in"] = s.expiresIn
|
||||
}
|
||||
|
||||
s.tokenRequestCount.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) signJWT() (string, error) {
|
||||
now := time.Now()
|
||||
header, _ := json.Marshal(map[string]string{
|
||||
"alg": "RS256",
|
||||
"kid": s.kid,
|
||||
"typ": "JWT",
|
||||
})
|
||||
claims, _ := json.Marshal(map[string]any{
|
||||
"iss": s.Issuer(),
|
||||
"sub": s.subject,
|
||||
"aud": s.audience,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(1 * time.Hour).Unix(),
|
||||
})
|
||||
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(header)
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claims)
|
||||
signingInput := headerB64 + "." + claimsB64
|
||||
|
||||
h := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, h[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
@@ -3,15 +3,44 @@ package process
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SafeBuffer is a thread-safe wrapper around bytes.Buffer.
|
||||
// It is safe to call Write and String concurrently.
|
||||
type SafeBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *SafeBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *SafeBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
type Process struct {
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
errorOutput *bytes.Buffer
|
||||
stdOutput *bytes.Buffer
|
||||
errorOutput *SafeBuffer
|
||||
stdOutput *SafeBuffer
|
||||
|
||||
done chan struct{}
|
||||
closeOne sync.Once
|
||||
waitErr error
|
||||
|
||||
started bool
|
||||
beforeStopHandler func()
|
||||
stopped bool
|
||||
}
|
||||
@@ -27,20 +56,45 @@ func NewWithEnvs(path string, params []string, envs []string) *Process {
|
||||
p := &Process{
|
||||
cmd: cmd,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
p.errorOutput = bytes.NewBufferString("")
|
||||
p.stdOutput = bytes.NewBufferString("")
|
||||
p.errorOutput = &SafeBuffer{}
|
||||
p.stdOutput = &SafeBuffer{}
|
||||
cmd.Stderr = p.errorOutput
|
||||
cmd.Stdout = p.stdOutput
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Process) Start() error {
|
||||
return p.cmd.Start()
|
||||
if p.started {
|
||||
return errors.New("process already started")
|
||||
}
|
||||
p.started = true
|
||||
|
||||
err := p.cmd.Start()
|
||||
if err != nil {
|
||||
p.waitErr = err
|
||||
p.closeDone()
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
p.waitErr = p.cmd.Wait()
|
||||
p.closeDone()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Process) closeDone() {
|
||||
p.closeOne.Do(func() { close(p.done) })
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the process exits.
|
||||
func (p *Process) Done() <-chan struct{} {
|
||||
return p.done
|
||||
}
|
||||
|
||||
func (p *Process) Stop() error {
|
||||
if p.stopped {
|
||||
if p.stopped || !p.started {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
@@ -50,7 +104,8 @@ func (p *Process) Stop() error {
|
||||
p.beforeStopHandler()
|
||||
}
|
||||
p.cancel()
|
||||
return p.cmd.Wait()
|
||||
<-p.done
|
||||
return p.waitErr
|
||||
}
|
||||
|
||||
func (p *Process) ErrorOutput() string {
|
||||
@@ -61,6 +116,38 @@ func (p *Process) StdOutput() string {
|
||||
return p.stdOutput.String()
|
||||
}
|
||||
|
||||
func (p *Process) Output() string {
|
||||
return p.stdOutput.String() + p.errorOutput.String()
|
||||
}
|
||||
|
||||
// CountOutput returns how many times pattern appears in the current accumulated output.
|
||||
func (p *Process) CountOutput(pattern string) int {
|
||||
return strings.Count(p.Output(), pattern)
|
||||
}
|
||||
|
||||
func (p *Process) SetBeforeStopHandler(fn func()) {
|
||||
p.beforeStopHandler = fn
|
||||
}
|
||||
|
||||
// WaitForOutput polls the combined process output until the pattern is found
|
||||
// count time(s) or the timeout is reached. It also returns early if the process exits.
|
||||
func (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
output := p.Output()
|
||||
if strings.Count(output, pattern) >= count {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-p.Done():
|
||||
// Process exited, check one last time.
|
||||
output = p.Output()
|
||||
if strings.Count(output, pattern) >= count {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("process exited before %d occurrence(s) of %q found", count, pattern)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for %d occurrence(s) of %q", count, pattern)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Annotations]", func() {
|
||||
"frp.e2e.test/bar" = "value2"
|
||||
`, framework.TCPEchoServerPort, p1Port)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(p1Port).Ensure()
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
for _, test := range tests {
|
||||
framework.NewRequestExpect(f).
|
||||
@@ -154,7 +154,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
for _, test := range tests {
|
||||
for domain := range strings.SplitSeq(test.customDomains, ",") {
|
||||
@@ -240,7 +240,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
|
||||
framework.ExpectNoError(err)
|
||||
@@ -426,7 +426,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
}
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
|
||||
|
||||
for _, test := range tests {
|
||||
timeout := time.Second
|
||||
@@ -505,7 +505,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
|
||||
}
|
||||
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
// Request without HTTP connect should get error
|
||||
framework.NewRequestExpect(f).
|
||||
|
||||
@@ -51,7 +51,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
|
||||
framework.TCPEchoServerPort, p2Port,
|
||||
framework.TCPEchoServerPort, p3Port)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(p1Port).Ensure()
|
||||
framework.NewRequestExpect(f).Port(p2Port).Ensure()
|
||||
@@ -93,7 +93,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
|
||||
webServer.password = "admin"
|
||||
`, dashboardPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPPath("/healthz")
|
||||
@@ -120,7 +120,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
|
||||
remotePort = %d
|
||||
`, adminPort, framework.TCPEchoServerPort, testPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(testPort).Ensure()
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
|
||||
clientConfs = append(clientConfs, client2Conf)
|
||||
}
|
||||
|
||||
f.RunProcesses([]string{serverConf}, clientConfs)
|
||||
f.RunProcesses(serverConf, clientConfs)
|
||||
|
||||
if configures.testDelay > 0 {
|
||||
time.Sleep(configures.testDelay)
|
||||
|
||||
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
|
||||
`, "`", "`", framework.TCPEchoServerPort, portName)
|
||||
|
||||
f.SetEnvs([]string{"FRP_TOKEN=123"})
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
})
|
||||
@@ -69,7 +69,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
|
||||
escapeTemplate("{{- end }}"),
|
||||
)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
client := f.APIClientForFrpc(adminPort)
|
||||
checkProxyFn := func(name string, localPort, remotePort int) {
|
||||
@@ -149,7 +149,7 @@ proxies:
|
||||
remotePort: %d
|
||||
`, port.GenName("Server"), framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
|
||||
@@ -161,7 +161,7 @@ proxies:
|
||||
"proxies": [{"name": "tcp", "type": "tcp", "localPort": {{ .%s }}, "remotePort": %d}]}`,
|
||||
port.GenName("Server"), framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
locations = ["/bar"]
|
||||
`, fooPort, barPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
@@ -117,7 +117,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
customDomains = ["normal.example.com"]
|
||||
`, fooPort, barPort, otherPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// user1
|
||||
framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort).
|
||||
@@ -159,7 +159,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
httpPassword = "test"
|
||||
`, framework.HTTPSimpleServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// not set auth header
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
@@ -196,7 +196,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
customDomains = ["*.example.com"]
|
||||
`, framework.HTTPSimpleServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// not match host
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
@@ -248,7 +248,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
subdomain = "bar"
|
||||
`, fooPort, barPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// foo
|
||||
framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort).
|
||||
@@ -290,7 +290,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
requestHeaders.set.x-from-where = "frp"
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
@@ -323,7 +323,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
responseHeaders.set.x-from-where = "frp"
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
@@ -357,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
hostHeaderRewrite = "rewrite.example.com"
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
@@ -406,7 +406,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
customDomains = ["127.0.0.1"]
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)}
|
||||
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
@@ -447,7 +447,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
|
||||
customDomains = ["normal.example.com"]
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
|
||||
192
test/e2e/v1/basic/oidc.go
Normal file
192
test/e2e/v1/basic/oidc.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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 basic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
|
||||
"github.com/fatedier/frp/test/e2e/framework"
|
||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||
"github.com/fatedier/frp/test/e2e/mock/server/oidcserver"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/port"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("[Feature: OIDC]", func() {
|
||||
f := framework.NewDefaultFramework()
|
||||
|
||||
ginkgo.It("should work with OIDC authentication", func() {
|
||||
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
|
||||
f.RunServer("", oidcSrv)
|
||||
|
||||
portName := port.GenName("TCP")
|
||||
|
||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||
auth.method = "oidc"
|
||||
auth.oidc.issuer = "%s"
|
||||
auth.oidc.audience = "frps"
|
||||
`, oidcSrv.Issuer())
|
||||
|
||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||
auth.method = "oidc"
|
||||
auth.oidc.clientID = "test-client"
|
||||
auth.oidc.clientSecret = "test-secret"
|
||||
auth.oidc.tokenEndpointURL = "%s"
|
||||
|
||||
[[proxies]]
|
||||
name = "tcp"
|
||||
type = "tcp"
|
||||
localPort = {{ .%s }}
|
||||
remotePort = {{ .%s }}
|
||||
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
|
||||
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
})
|
||||
|
||||
ginkgo.It("should authenticate heartbeats with OIDC", func() {
|
||||
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
|
||||
f.RunServer("", oidcSrv)
|
||||
|
||||
serverPort := f.AllocPort()
|
||||
remotePort := f.AllocPort()
|
||||
|
||||
serverConf := fmt.Sprintf(`
|
||||
bindAddr = "0.0.0.0"
|
||||
bindPort = %d
|
||||
log.level = "trace"
|
||||
auth.method = "oidc"
|
||||
auth.additionalScopes = ["HeartBeats"]
|
||||
auth.oidc.issuer = "%s"
|
||||
auth.oidc.audience = "frps"
|
||||
`, serverPort, oidcSrv.Issuer())
|
||||
|
||||
clientConf := fmt.Sprintf(`
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = %d
|
||||
loginFailExit = false
|
||||
log.level = "trace"
|
||||
auth.method = "oidc"
|
||||
auth.additionalScopes = ["HeartBeats"]
|
||||
auth.oidc.clientID = "test-client"
|
||||
auth.oidc.clientSecret = "test-secret"
|
||||
auth.oidc.tokenEndpointURL = "%s"
|
||||
transport.heartbeatInterval = 1
|
||||
|
||||
[[proxies]]
|
||||
name = "tcp"
|
||||
type = "tcp"
|
||||
localPort = %d
|
||||
remotePort = %d
|
||||
`, serverPort, oidcSrv.TokenEndpoint(), f.PortByName(framework.TCPEchoServerPort), remotePort)
|
||||
|
||||
serverConfigPath := f.GenerateConfigFile(serverConf)
|
||||
clientConfigPath := f.GenerateConfigFile(clientConf)
|
||||
|
||||
_, _, err := f.RunFrps("-c", serverConfigPath)
|
||||
framework.ExpectNoError(err)
|
||||
clientProcess, _, err := f.RunFrpc("-c", clientConfigPath)
|
||||
framework.ExpectNoError(err)
|
||||
|
||||
// Wait for several authenticated heartbeat cycles instead of a fixed sleep.
|
||||
err = clientProcess.WaitForOutput("send heartbeat to server", 3, 10*time.Second)
|
||||
framework.ExpectNoError(err)
|
||||
|
||||
// Proxy should still work: heartbeat auth has not failed.
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
|
||||
ginkgo.It("should work when token has no expires_in", func() {
|
||||
oidcSrv := oidcserver.New(
|
||||
oidcserver.WithBindPort(f.AllocPort()),
|
||||
oidcserver.WithExpiresIn(0),
|
||||
)
|
||||
f.RunServer("", oidcSrv)
|
||||
|
||||
portName := port.GenName("TCP")
|
||||
|
||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||
auth.method = "oidc"
|
||||
auth.oidc.issuer = "%s"
|
||||
auth.oidc.audience = "frps"
|
||||
`, oidcSrv.Issuer())
|
||||
|
||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||
auth.method = "oidc"
|
||||
auth.additionalScopes = ["HeartBeats"]
|
||||
auth.oidc.clientID = "test-client"
|
||||
auth.oidc.clientSecret = "test-secret"
|
||||
auth.oidc.tokenEndpointURL = "%s"
|
||||
transport.heartbeatInterval = 1
|
||||
|
||||
[[proxies]]
|
||||
name = "tcp"
|
||||
type = "tcp"
|
||||
localPort = {{ .%s }}
|
||||
remotePort = {{ .%s }}
|
||||
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
|
||||
|
||||
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
|
||||
countAfterLogin := oidcSrv.TokenRequestCount()
|
||||
|
||||
// Wait for several heartbeat cycles instead of a fixed sleep.
|
||||
// Each heartbeat fetches a fresh token in non-caching mode.
|
||||
err := clientProcesses[0].WaitForOutput("send heartbeat to server", 3, 10*time.Second)
|
||||
framework.ExpectNoError(err)
|
||||
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
|
||||
// Each heartbeat should have fetched a new token (non-caching mode).
|
||||
countAfterHeartbeats := oidcSrv.TokenRequestCount()
|
||||
framework.ExpectTrue(
|
||||
countAfterHeartbeats > countAfterLogin,
|
||||
"expected additional token requests for heartbeats, got %d before and %d after",
|
||||
countAfterLogin, countAfterHeartbeats,
|
||||
)
|
||||
})
|
||||
|
||||
ginkgo.It("should reject invalid OIDC credentials", func() {
|
||||
oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
|
||||
f.RunServer("", oidcSrv)
|
||||
|
||||
portName := port.GenName("TCP")
|
||||
|
||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||
auth.method = "oidc"
|
||||
auth.oidc.issuer = "%s"
|
||||
auth.oidc.audience = "frps"
|
||||
`, oidcSrv.Issuer())
|
||||
|
||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||
auth.method = "oidc"
|
||||
auth.oidc.clientID = "test-client"
|
||||
auth.oidc.clientSecret = "wrong-secret"
|
||||
auth.oidc.tokenEndpointURL = "%s"
|
||||
|
||||
[[proxies]]
|
||||
name = "tcp"
|
||||
type = "tcp"
|
||||
localPort = {{ .%s }}
|
||||
remotePort = {{ .%s }}
|
||||
`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
|
||||
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
|
||||
})
|
||||
})
|
||||
@@ -67,7 +67,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
||||
remotePort = 11003
|
||||
`, framework.UDPEchoServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// TCP
|
||||
// Allowed in range
|
||||
@@ -108,7 +108,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
||||
localPort = {{ .%s }}
|
||||
`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
client := f.APIClientForFrpc(adminPort)
|
||||
|
||||
@@ -150,7 +150,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
||||
customDomains = ["example.com"]
|
||||
`, framework.HTTPSimpleServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPHost("example.com")
|
||||
@@ -178,7 +178,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
|
||||
customDomains = ["example.com"]
|
||||
`, framework.HTTPSimpleServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPPath("/healthz")
|
||||
|
||||
@@ -79,7 +79,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
|
||||
customDomains = ["normal.example.com"]
|
||||
`, fooPort, barPort, otherPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// user1
|
||||
framework.NewRequestExpect(f).Explain("user1").
|
||||
@@ -125,7 +125,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
|
||||
httpPassword = "test"
|
||||
`, fooPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// not set auth header
|
||||
framework.NewRequestExpect(f).Explain("no auth").
|
||||
@@ -209,7 +209,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
|
||||
customDomains = ["normal.example.com"]
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).
|
||||
RequestModify(func(r *request.Request) {
|
||||
|
||||
@@ -16,8 +16,11 @@ package basic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
|
||||
@@ -73,7 +76,7 @@ localPort = {{ .%s }}
|
||||
remotePort = {{ .%s }}
|
||||
`, tokenContent, framework.TCPEchoServerPort, portName)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
})
|
||||
@@ -109,7 +112,7 @@ localPort = {{ .%s }}
|
||||
remotePort = {{ .%s }}
|
||||
`, tokenFile, framework.TCPEchoServerPort, portName)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
})
|
||||
@@ -150,7 +153,7 @@ localPort = {{ .%s }}
|
||||
remotePort = {{ .%s }}
|
||||
`, clientTokenFile, framework.TCPEchoServerPort, portName)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).PortName(portName).Ensure()
|
||||
})
|
||||
@@ -190,7 +193,7 @@ localPort = {{ .%s }}
|
||||
remotePort = {{ .%s }}
|
||||
`, clientTokenFile, framework.TCPEchoServerPort, portName)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// This should fail due to token mismatch - the client should not be able to connect
|
||||
// We expect the request to fail because the proxy tunnel is not established
|
||||
@@ -198,32 +201,27 @@ remotePort = {{ .%s }}
|
||||
})
|
||||
|
||||
ginkgo.It("should fail with non-existent token file", func() {
|
||||
// This test verifies that server fails to start when tokenSource points to non-existent file
|
||||
// We'll verify this by checking that the configuration loading itself fails
|
||||
|
||||
// Create a config that references a non-existent file
|
||||
tmpDir := f.TempDirectory
|
||||
nonExistentFile := filepath.Join(tmpDir, "non_existent_token")
|
||||
|
||||
serverConf := consts.DefaultServerConfig
|
||||
|
||||
// Server config with non-existent tokenSource file
|
||||
serverConf += fmt.Sprintf(`
|
||||
serverPort := f.AllocPort()
|
||||
serverConf := fmt.Sprintf(`
|
||||
bindAddr = "0.0.0.0"
|
||||
bindPort = %d
|
||||
auth.tokenSource.type = "file"
|
||||
auth.tokenSource.file.path = "%s"
|
||||
`, nonExistentFile)
|
||||
`, serverPort, nonExistentFile)
|
||||
|
||||
// The test expectation is that this will fail during the RunProcesses call
|
||||
// because the server cannot load the configuration due to missing token file
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Expected: server should fail to start due to missing file
|
||||
ginkgo.By(fmt.Sprintf("Server correctly failed to start: %v", r))
|
||||
}
|
||||
}()
|
||||
serverConfigPath := f.GenerateConfigFile(serverConf)
|
||||
|
||||
// This should cause a panic or error during server startup
|
||||
f.RunProcesses([]string{serverConf}, []string{})
|
||||
_, _, _ = f.RunFrps("-c", serverConfigPath)
|
||||
|
||||
// Server should have failed to start, so the port should not be listening.
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(serverPort)), 1*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
}
|
||||
framework.ExpectTrue(err != nil, "server should not be listening on port %d", serverPort)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ var _ = ginkgo.Describe("[Feature: XTCP]", func() {
|
||||
fallbackTimeoutMs = 200
|
||||
`, framework.TCPEchoServerPort, bindPortName)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.NewRequestExpect(f).
|
||||
RequestModify(func(r *request.Request) {
|
||||
r.Timeout(time.Second)
|
||||
|
||||
@@ -36,7 +36,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
|
||||
transport.bandwidthLimit = "10KB"
|
||||
`, localPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
content := strings.Repeat("a", 50*1024) // 5KB
|
||||
start := time.Now()
|
||||
@@ -92,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
|
||||
remotePort = %d
|
||||
`, localPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
content := strings.Repeat("a", 50*1024) // 5KB
|
||||
start := time.Now()
|
||||
|
||||
@@ -41,24 +41,24 @@ var _ = ginkgo.Describe("[Feature: Chaos]", func() {
|
||||
|
||||
// 2. stop frps, expect request failed
|
||||
_ = ps.Stop()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
|
||||
|
||||
// 3. restart frps, expect request success
|
||||
successCount := pc.CountOutput("[tcp] start proxy success")
|
||||
_, _, err = f.RunFrps("-c", serverConfigPath)
|
||||
framework.ExpectNoError(err)
|
||||
time.Sleep(2 * time.Second)
|
||||
framework.ExpectNoError(pc.WaitForOutput("[tcp] start proxy success", successCount+1, 5*time.Second))
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
// 4. stop frpc, expect request failed
|
||||
_ = pc.Stop()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
|
||||
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
|
||||
|
||||
// 5. restart frpc, expect request success
|
||||
_, _, err = f.RunFrpc("-c", clientConfigPath)
|
||||
newPc, _, err := f.RunFrpc("-c", clientConfigPath)
|
||||
framework.ExpectNoError(err)
|
||||
time.Sleep(time.Second)
|
||||
framework.ExpectNoError(newPc.WaitForOutput("[tcp] start proxy success", 1, 5*time.Second))
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
loadBalancer.groupKey = "123"
|
||||
`, fooPort, remotePort, barPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
fooCount := 0
|
||||
barCount := 0
|
||||
@@ -157,7 +157,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
loadBalancer.groupKey = "123"
|
||||
`, fooPort, barPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
fooCount := 0
|
||||
barCount := 0
|
||||
@@ -186,6 +186,68 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
|
||||
framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount)
|
||||
})
|
||||
|
||||
ginkgo.It("TCPMux httpconnect", func() {
|
||||
vhostPort := f.AllocPort()
|
||||
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
|
||||
tcpmuxHTTPConnectPort = %d
|
||||
`, vhostPort)
|
||||
clientConf := consts.DefaultClientConfig
|
||||
|
||||
fooPort := f.AllocPort()
|
||||
fooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte("foo")))
|
||||
f.RunServer("", fooServer)
|
||||
|
||||
barPort := f.AllocPort()
|
||||
barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar")))
|
||||
f.RunServer("", barServer)
|
||||
|
||||
clientConf += fmt.Sprintf(`
|
||||
[[proxies]]
|
||||
name = "foo"
|
||||
type = "tcpmux"
|
||||
multiplexer = "httpconnect"
|
||||
localPort = %d
|
||||
customDomains = ["tcpmux-group.example.com"]
|
||||
loadBalancer.group = "test"
|
||||
loadBalancer.groupKey = "123"
|
||||
|
||||
[[proxies]]
|
||||
name = "bar"
|
||||
type = "tcpmux"
|
||||
multiplexer = "httpconnect"
|
||||
localPort = %d
|
||||
customDomains = ["tcpmux-group.example.com"]
|
||||
loadBalancer.group = "test"
|
||||
loadBalancer.groupKey = "123"
|
||||
`, fooPort, barPort)
|
||||
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
proxyURL := fmt.Sprintf("http://127.0.0.1:%d", vhostPort)
|
||||
fooCount := 0
|
||||
barCount := 0
|
||||
for i := range 10 {
|
||||
framework.NewRequestExpect(f).
|
||||
Explain("times " + strconv.Itoa(i)).
|
||||
RequestModify(func(r *request.Request) {
|
||||
r.Addr("tcpmux-group.example.com").Proxy(proxyURL)
|
||||
}).
|
||||
Ensure(func(resp *request.Response) bool {
|
||||
switch string(resp.Content) {
|
||||
case "foo":
|
||||
fooCount++
|
||||
case "bar":
|
||||
barCount++
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount)
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("Health Check", func() {
|
||||
@@ -224,7 +286,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
healthCheck.intervalSeconds = 1
|
||||
`, fooPort, remotePort, barPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// check foo and bar is ok
|
||||
results := []string{}
|
||||
@@ -237,15 +299,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
|
||||
// close bar server, check foo is ok
|
||||
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
|
||||
barServer.Close()
|
||||
time.Sleep(2 * time.Second)
|
||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
|
||||
for range 10 {
|
||||
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
|
||||
}
|
||||
|
||||
// resume bar server, check foo and bar is ok
|
||||
successCount := clientProcesses[0].CountOutput("[bar] health check success")
|
||||
f.RunServer("", barServer)
|
||||
time.Sleep(2 * time.Second)
|
||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
|
||||
results = []string{}
|
||||
for range 10 {
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
|
||||
@@ -295,7 +359,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
healthCheck.path = "/healthz"
|
||||
`, fooPort, barPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// send first HTTP request
|
||||
var contents []string
|
||||
@@ -325,15 +389,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
|
||||
// close bar server, check foo is ok
|
||||
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
|
||||
barServer.Close()
|
||||
time.Sleep(2 * time.Second)
|
||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
|
||||
results = doFooBarHTTPRequest(vhostPort, "example.com")
|
||||
framework.ExpectContainElements(results, []string{"foo"})
|
||||
framework.ExpectNotContainElements(results, []string{"bar"})
|
||||
|
||||
// resume bar server, check foo and bar is ok
|
||||
successCount := clientProcesses[0].CountOutput("[bar] health check success")
|
||||
f.RunServer("", barServer)
|
||||
time.Sleep(2 * time.Second)
|
||||
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
|
||||
results = doFooBarHTTPRequest(vhostPort, "example.com")
|
||||
framework.ExpectContainElements(results, []string{"foo", "bar"})
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ var _ = ginkgo.Describe("[Feature: Heartbeat]", func() {
|
||||
`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)
|
||||
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure()
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ var _ = ginkgo.Describe("[Feature: Monitor]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
@@ -48,7 +48,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
customDomains = ["normal.example.com"]
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
@@ -82,7 +82,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
customDomains = ["normal.example.com"]
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).
|
||||
RequestModify(func(r *request.Request) {
|
||||
@@ -112,7 +112,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
localAddr = "127.0.0.1:%d"
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
|
||||
framework.ExpectNoError(err)
|
||||
@@ -154,7 +154,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
localAddr = "127.0.0.1:%d"
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
localServer := httpserver.New(
|
||||
httpserver.WithBindPort(localPort),
|
||||
@@ -212,7 +212,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
transport.proxyProtocolVersion = "v2"
|
||||
`, localPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
|
||||
log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
|
||||
@@ -262,7 +262,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
transport.proxyProtocolVersion = "v2"
|
||||
`, localPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Protocol("udp").Port(remotePort).Ensure(func(resp *request.Response) bool {
|
||||
log.Tracef("udp proxy protocol get SourceAddr: %s", string(resp.Content))
|
||||
@@ -309,7 +309,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
|
||||
transport.proxyProtocolVersion = "v2"
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().HTTPHost("normal.example.com")
|
||||
|
||||
@@ -3,6 +3,8 @@ package features
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
@@ -25,7 +27,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
|
||||
sshTunnelGateway.bindPort = %d
|
||||
`, sshPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, nil)
|
||||
f.RunProcesses(serverConf, nil)
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
|
||||
|
||||
localPort := f.PortByName(framework.TCPEchoServerPort)
|
||||
remotePort := f.AllocPort()
|
||||
@@ -49,7 +52,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
|
||||
sshTunnelGateway.bindPort = %d
|
||||
`, vhostPort, sshPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, nil)
|
||||
f.RunProcesses(serverConf, nil)
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
|
||||
|
||||
localPort := f.PortByName(framework.HTTPSimpleServerPort)
|
||||
tc := ssh.NewTunnelClient(
|
||||
@@ -76,7 +80,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
|
||||
sshTunnelGateway.bindPort = %d
|
||||
`, vhostPort, sshPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, nil)
|
||||
f.RunProcesses(serverConf, nil)
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
|
||||
|
||||
localPort := f.AllocPort()
|
||||
testDomain := "test.example.com"
|
||||
@@ -118,7 +123,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
|
||||
sshTunnelGateway.bindPort = %d
|
||||
`, tcpmuxPort, sshPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, nil)
|
||||
f.RunProcesses(serverConf, nil)
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
|
||||
|
||||
localPort := f.AllocPort()
|
||||
testDomain := "test.example.com"
|
||||
@@ -173,7 +179,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
|
||||
bindPort = %d
|
||||
`, bindPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{visitorConf})
|
||||
f.RunProcesses(serverConf, []string{visitorConf})
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
|
||||
|
||||
localPort := f.PortByName(framework.TCPEchoServerPort)
|
||||
tc := ssh.NewTunnelClient(
|
||||
|
||||
@@ -30,8 +30,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
path = "%s/store.json"
|
||||
`, adminPort, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||
|
||||
proxyConfig := map[string]any{
|
||||
"name": "test-tcp",
|
||||
@@ -52,7 +52,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
return resp.Code == 200
|
||||
})
|
||||
|
||||
time.Sleep(time.Second)
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second))
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
@@ -71,8 +71,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
path = "%s/store.json"
|
||||
`, adminPort, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||
|
||||
proxyConfig := map[string]any{
|
||||
"name": "test-tcp",
|
||||
@@ -93,7 +93,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
return resp.Code == 200
|
||||
})
|
||||
|
||||
time.Sleep(time.Second)
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort1), 5*time.Second))
|
||||
framework.NewRequestExpect(f).Port(remotePort1).Ensure()
|
||||
|
||||
proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2
|
||||
@@ -107,7 +107,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
return resp.Code == 200
|
||||
})
|
||||
|
||||
time.Sleep(time.Second)
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort2), 5*time.Second))
|
||||
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort1), 100*time.Millisecond, 5*time.Second))
|
||||
framework.NewRequestExpect(f).Port(remotePort2).Ensure()
|
||||
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()
|
||||
})
|
||||
@@ -125,8 +126,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
path = "%s/store.json"
|
||||
`, adminPort, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||
|
||||
proxyConfig := map[string]any{
|
||||
"name": "test-tcp",
|
||||
@@ -147,7 +148,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
return resp.Code == 200
|
||||
})
|
||||
|
||||
time.Sleep(time.Second)
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second))
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
@@ -156,7 +157,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
return resp.Code == 200
|
||||
})
|
||||
|
||||
time.Sleep(time.Second)
|
||||
framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
|
||||
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
|
||||
})
|
||||
|
||||
@@ -173,8 +174,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
path = "%s/store.json"
|
||||
`, adminPort, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||
|
||||
proxyConfig := map[string]any{
|
||||
"name": "test-tcp",
|
||||
@@ -195,8 +196,6 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
return resp.Code == 200
|
||||
})
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
|
||||
}).Ensure(func(resp *request.Response) bool {
|
||||
@@ -225,8 +224,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
webServer.port = %d
|
||||
`, adminPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
|
||||
@@ -247,8 +246,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
path = "%s/store.json"
|
||||
`, adminPort, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||
|
||||
invalidBody, _ := json.Marshal(map[string]any{
|
||||
"name": "bad-proxy",
|
||||
@@ -280,8 +279,8 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
path = "%s/store.json"
|
||||
`, adminPort, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
|
||||
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": "proxy-a",
|
||||
|
||||
@@ -74,7 +74,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
|
||||
}
|
||||
// run frps and frpc
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf.String()})
|
||||
f.RunProcesses(serverConf, []string{clientConf.String()})
|
||||
|
||||
for _, test := range tests {
|
||||
framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()
|
||||
@@ -98,7 +98,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
httpPassword = "123"
|
||||
`, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// http proxy, no auth info
|
||||
framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {
|
||||
@@ -132,7 +132,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
password = "123"
|
||||
`, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// http proxy, no auth info
|
||||
framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {
|
||||
@@ -182,7 +182,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
httpPassword = "123"
|
||||
`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
// from tcp proxy
|
||||
framework.NewRequestExpect(f).Request(
|
||||
@@ -218,7 +218,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
localAddr = "127.0.0.1:%d"
|
||||
`, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
|
||||
framework.ExpectNoError(err)
|
||||
@@ -264,7 +264,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
keyPath = "%s"
|
||||
`, localPort, crtPath, keyPath)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
localServer := httpserver.New(
|
||||
httpserver.WithBindPort(localPort),
|
||||
@@ -310,7 +310,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
keyPath = "%s"
|
||||
`, localPort, crtPath, keyPath)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
|
||||
framework.ExpectNoError(err)
|
||||
@@ -350,7 +350,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
hostHeaderRewrite = "rewrite.test.com"
|
||||
`, remotePort, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
localServer := httpserver.New(
|
||||
httpserver.WithBindPort(localPort),
|
||||
@@ -385,7 +385,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
requestHeaders.set.x-from-where = "frp"
|
||||
`, remotePort, localPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
localServer := httpserver.New(
|
||||
httpserver.WithBindPort(localPort),
|
||||
@@ -431,7 +431,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
keyPath = "%s"
|
||||
`, localPort, crtPath, keyPath)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
localServer := httpserver.New(
|
||||
httpserver.WithBindPort(localPort),
|
||||
|
||||
@@ -74,7 +74,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort2)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf, invalidTokenClientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()
|
||||
@@ -124,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
@@ -160,7 +160,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remotePort = 0
|
||||
`, framework.TCPEchoServerPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
@@ -204,7 +204,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
_, clients := f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
_, clients := f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
@@ -261,7 +261,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
@@ -310,7 +310,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
@@ -357,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
@@ -406,7 +406,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
|
||||
remotePort = %d
|
||||
`, framework.TCPEchoServerPort, remotePort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
f.RunProcesses(serverConf, []string{clientConf})
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
|
||||
39
web/frpc/components.d.ts
vendored
39
web/frpc/components.d.ts
vendored
@@ -7,28 +7,45 @@ export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
||||
ActionButton: typeof import('./src/components/ActionButton.vue')['default']
|
||||
BaseDialog: typeof import('./src/components/BaseDialog.vue')['default']
|
||||
ConfigField: typeof import('./src/components/ConfigField.vue')['default']
|
||||
ConfigSection: typeof import('./src/components/ConfigSection.vue')['default']
|
||||
ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
FilterDropdown: typeof import('./src/components/FilterDropdown.vue')['default']
|
||||
KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default']
|
||||
PopoverMenu: typeof import('./src/components/PopoverMenu.vue')['default']
|
||||
PopoverMenuItem: typeof import('./src/components/PopoverMenuItem.vue')['default']
|
||||
ProxyAuthSection: typeof import('./src/components/proxy-form/ProxyAuthSection.vue')['default']
|
||||
ProxyBackendSection: typeof import('./src/components/proxy-form/ProxyBackendSection.vue')['default']
|
||||
ProxyBaseSection: typeof import('./src/components/proxy-form/ProxyBaseSection.vue')['default']
|
||||
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
|
||||
ProxyFormLayout: typeof import('./src/components/proxy-form/ProxyFormLayout.vue')['default']
|
||||
ProxyHealthSection: typeof import('./src/components/proxy-form/ProxyHealthSection.vue')['default']
|
||||
ProxyHttpSection: typeof import('./src/components/proxy-form/ProxyHttpSection.vue')['default']
|
||||
ProxyLoadBalanceSection: typeof import('./src/components/proxy-form/ProxyLoadBalanceSection.vue')['default']
|
||||
ProxyMetadataSection: typeof import('./src/components/proxy-form/ProxyMetadataSection.vue')['default']
|
||||
ProxyNatSection: typeof import('./src/components/proxy-form/ProxyNatSection.vue')['default']
|
||||
ProxyRemoteSection: typeof import('./src/components/proxy-form/ProxyRemoteSection.vue')['default']
|
||||
ProxyTransportSection: typeof import('./src/components/proxy-form/ProxyTransportSection.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
StatCard: typeof import('./src/components/StatCard.vue')['default']
|
||||
StatusPills: typeof import('./src/components/StatusPills.vue')['default']
|
||||
StringListEditor: typeof import('./src/components/StringListEditor.vue')['default']
|
||||
VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']
|
||||
VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']
|
||||
VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']
|
||||
VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']
|
||||
VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>frp client</title>
|
||||
</head>
|
||||
|
||||
|
||||
178
web/frpc/package-lock.json
generated
178
web/frpc/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"element-plus": "^2.13.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
@@ -1659,16 +1660,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2072,6 +2063,36 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"speakingurl": "^14.0.1",
|
||||
"superjson": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-kit/node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/eslint-config-prettier": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz",
|
||||
@@ -2430,6 +2451,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -2713,6 +2743,21 @@
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/copy-anything": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-what": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3903,9 +3948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -4223,6 +4268,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
|
||||
@@ -4241,9 +4292,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
||||
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4701,6 +4752,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-what": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
@@ -4974,6 +5037,12 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
|
||||
@@ -5582,6 +5651,36 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
@@ -5859,6 +5958,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown-string": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown-string/-/rolldown-string-0.2.1.tgz",
|
||||
@@ -6020,6 +6125,16 @@
|
||||
"@parcel/watcher": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
|
||||
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
@@ -6267,6 +6382,15 @@
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -6395,6 +6519,18 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -6422,19 +6558,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svgo": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
|
||||
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz",
|
||||
"integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@trysound/sax": "0.2.0",
|
||||
"commander": "^7.2.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^2.3.1",
|
||||
"css-what": "^6.1.0",
|
||||
"csso": "^5.0.5",
|
||||
"picocolors": "^1.0.0"
|
||||
"picocolors": "^1.0.0",
|
||||
"sax": "^1.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"svgo": "bin/svgo"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"element-plus": "^2.13.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
|
||||
@@ -2,140 +2,160 @@
|
||||
<div id="app">
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-top">
|
||||
<div class="brand-section">
|
||||
<div class="logo-wrapper">
|
||||
<LogoIcon class="logo-icon" />
|
||||
</div>
|
||||
<span class="divider">/</span>
|
||||
<span class="brand-name">frp</span>
|
||||
<span class="badge client-badge">Client</span>
|
||||
<span class="badge" v-if="currentRouteName">{{
|
||||
currentRouteName
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="header-controls">
|
||||
<a
|
||||
class="github-link"
|
||||
href="https://github.com/fatedier/frp"
|
||||
target="_blank"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<GitHubIcon class="github-icon" />
|
||||
</a>
|
||||
<el-switch
|
||||
v-model="isDark"
|
||||
inline-prompt
|
||||
:active-icon="Moon"
|
||||
:inactive-icon="Sunny"
|
||||
class="theme-switch"
|
||||
/>
|
||||
<div class="brand-section">
|
||||
<button v-if="isMobile" class="hamburger-btn" @click="toggleSidebar" aria-label="Toggle menu">
|
||||
<span class="hamburger-icon">☰</span>
|
||||
</button>
|
||||
<div class="logo-wrapper">
|
||||
<LogoIcon class="logo-icon" />
|
||||
</div>
|
||||
<span class="divider">/</span>
|
||||
<span class="brand-name">frp</span>
|
||||
<span class="badge">Client</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav-bar">
|
||||
<router-link to="/" class="nav-link" active-class="active"
|
||||
>Overview</router-link
|
||||
<div class="header-controls">
|
||||
<a
|
||||
class="github-link"
|
||||
href="https://github.com/fatedier/frp"
|
||||
target="_blank"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<router-link to="/configure" class="nav-link" active-class="active"
|
||||
>Configure</router-link
|
||||
>
|
||||
</nav>
|
||||
<GitHubIcon class="github-icon" />
|
||||
</a>
|
||||
<el-switch
|
||||
v-model="isDark"
|
||||
inline-prompt
|
||||
:active-icon="Moon"
|
||||
:inactive-icon="Sunny"
|
||||
class="theme-switch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="content">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
<div class="layout">
|
||||
<!-- Mobile overlay -->
|
||||
<div
|
||||
v-if="isMobile && sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="closeSidebar"
|
||||
/>
|
||||
|
||||
<aside class="sidebar" :class="{ 'mobile-open': isMobile && sidebarOpen }">
|
||||
<nav class="sidebar-nav">
|
||||
<router-link
|
||||
to="/proxies"
|
||||
class="sidebar-link"
|
||||
:class="{ active: route.path.startsWith('/proxies') }"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
Proxies
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/visitors"
|
||||
class="sidebar-link"
|
||||
:class="{ active: route.path.startsWith('/visitors') }"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
Visitors
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/config"
|
||||
class="sidebar-link"
|
||||
:class="{ active: route.path === '/config' }"
|
||||
@click="closeSidebar"
|
||||
>
|
||||
Config
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main id="content">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import GitHubIcon from './assets/icons/github.svg?component'
|
||||
import LogoIcon from './assets/icons/logo.svg?component'
|
||||
import { useResponsive } from './composables/useResponsive'
|
||||
|
||||
const route = useRoute()
|
||||
const isDark = useDark()
|
||||
const { isMobile } = useResponsive()
|
||||
|
||||
const currentRouteName = computed(() => {
|
||||
if (route.path === '/') return 'Overview'
|
||||
if (route.path === '/configure') return 'Configure'
|
||||
if (route.path === '/proxies/create') return 'Create Proxy'
|
||||
if (route.path.startsWith('/proxies/') && route.path.endsWith('/edit'))
|
||||
return 'Edit Proxy'
|
||||
if (route.path === '/visitors/create') return 'Create Visitor'
|
||||
if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit'))
|
||||
return 'Edit Visitor'
|
||||
return ''
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const closeSidebar = () => {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Auto-close sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
if (isMobile.value) {
|
||||
closeSidebar()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--header-height: 112px;
|
||||
--header-bg: rgba(255, 255, 255, 0.8);
|
||||
--header-border: #eaeaea;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
--hover-bg: #f5f5f5;
|
||||
--active-link: #000;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--header-bg: rgba(0, 0, 0, 0.8);
|
||||
--header-border: #333;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: #888;
|
||||
--hover-bg: #1a1a1a;
|
||||
--active-link: #fff;
|
||||
}
|
||||
|
||||
<style lang="scss">
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
font-family: ui-sans-serif, -apple-system, system-ui, Segoe UI, Helvetica,
|
||||
Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--el-bg-color-page);
|
||||
*,
|
||||
:after,
|
||||
:before {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $color-bg-secondary;
|
||||
}
|
||||
|
||||
// Header
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
flex-shrink: 0;
|
||||
background: $color-bg-primary;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
height: $header-height;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 $spacing-xl;
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
@@ -144,41 +164,30 @@ body {
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--header-border);
|
||||
font-size: 24px;
|
||||
color: $color-border;
|
||||
font-size: 22px;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: $font-size-xl;
|
||||
color: $color-text-primary;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--hover-bg);
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-text-muted;
|
||||
background: $color-bg-muted;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
.badge.client-badge {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
html.dark .badge.client-badge {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
@@ -188,17 +197,17 @@ html.dark .badge.client-badge {
|
||||
}
|
||||
|
||||
.github-link {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.2s;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
@include flex-center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: $radius-sm;
|
||||
color: $color-text-secondary;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.github-icon {
|
||||
@@ -206,15 +215,10 @@ html.dark .badge.client-badge {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.github-link:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--header-border);
|
||||
}
|
||||
|
||||
.theme-switch {
|
||||
--el-switch-on-color: #2c2c3a;
|
||||
--el-switch-off-color: #f2f2f2;
|
||||
--el-switch-border-color: var(--header-border);
|
||||
--el-switch-border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
html.dark .theme-switch {
|
||||
@@ -225,47 +229,300 @@ html.dark .theme-switch {
|
||||
color: #909399 !important;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
height: 48px;
|
||||
// Layout
|
||||
.layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
.sidebar {
|
||||
width: $sidebar-width;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid $color-border-light;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
@include flex-column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
font-size: $font-size-lg;
|
||||
color: $color-text-secondary;
|
||||
padding: 10px $spacing-md;
|
||||
border-radius: $radius-sm;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $color-text-primary;
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $color-text-primary;
|
||||
background: $color-bg-hover;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
// Hamburger button (mobile only)
|
||||
.hamburger-btn {
|
||||
@include flex-center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--active-link);
|
||||
border-bottom-color: var(--active-link);
|
||||
.hamburger-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
// Mobile overlay
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: $color-bg-primary;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
// Common page styles
|
||||
.page-title {
|
||||
font-size: $font-size-xl + 2px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-muted;
|
||||
margin: $spacing-sm 0 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@include flex-center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
background: transparent;
|
||||
color: $color-text-muted;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 10px;
|
||||
background: $color-bg-tertiary;
|
||||
box-shadow: 0 0 0 1px $color-border inset;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px $color-text-light inset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.el-input__prefix {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Element Plus global overrides
|
||||
.el-button {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.el-switch {
|
||||
--el-switch-on-color: #606266;
|
||||
--el-switch-off-color: #dcdfe6;
|
||||
}
|
||||
|
||||
html.dark .el-switch {
|
||||
--el-switch-on-color: #b0b0b0;
|
||||
--el-switch-off-color: #404040;
|
||||
}
|
||||
|
||||
.el-radio {
|
||||
--el-radio-text-color: var(--color-text-primary) !important;
|
||||
--el-radio-input-border-color-hover: #606266 !important;
|
||||
--el-color-primary: #606266 !important;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.el-loading-mask {
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
// Select overrides
|
||||
.el-select__wrapper {
|
||||
border-radius: $radius-md !important;
|
||||
box-shadow: 0 0 0 1px $color-border-light inset !important;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px $color-border inset !important;
|
||||
}
|
||||
|
||||
&.is-focused {
|
||||
box-shadow: 0 0 0 1px $color-border inset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-select-dropdown {
|
||||
border-radius: 12px !important;
|
||||
border: 1px solid $color-border-light !important;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
|
||||
padding: 4px !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item {
|
||||
border-radius: $radius-sm;
|
||||
margin: 2px 0;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&.is-selected {
|
||||
color: $color-text-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
// Input overrides
|
||||
.el-input__wrapper {
|
||||
border-radius: $radius-md !important;
|
||||
box-shadow: 0 0 0 1px $color-border-light inset !important;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px $color-border inset !important;
|
||||
}
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px $color-border inset !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Status pill (shared)
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
text-transform: capitalize;
|
||||
|
||||
&.running {
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(245, 108, 108, 0.1);
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.waiting {
|
||||
background: rgba(230, 162, 60, 0.1);
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: $color-bg-muted;
|
||||
color: $color-text-light;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@include mobile {
|
||||
.header-content {
|
||||
padding: 0 20px;
|
||||
padding: 0 $spacing-lg;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: $header-height;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
background: $color-bg-primary;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-right: 1px solid $color-border-light;
|
||||
|
||||
&.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Select dropdown overflow prevention
|
||||
.el-select-dropdown {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ProxyDefinition,
|
||||
VisitorListResp,
|
||||
VisitorDefinition,
|
||||
} from '../types/proxy'
|
||||
} from '../types'
|
||||
|
||||
export const getStatus = () => {
|
||||
return http.get<StatusResponse>('/api/status')
|
||||
@@ -23,6 +23,19 @@ export const reloadConfig = () => {
|
||||
return http.get<void>('/api/reload')
|
||||
}
|
||||
|
||||
// Config lookup API (any source)
|
||||
export const getProxyConfig = (name: string) => {
|
||||
return http.get<ProxyDefinition>(
|
||||
`/api/proxy/${encodeURIComponent(name)}/config`,
|
||||
)
|
||||
}
|
||||
|
||||
export const getVisitorConfig = (name: string) => {
|
||||
return http.get<VisitorDefinition>(
|
||||
`/api/visitor/${encodeURIComponent(name)}/config`,
|
||||
)
|
||||
}
|
||||
|
||||
// Store API - Proxies
|
||||
export const listStoreProxies = () => {
|
||||
return http.get<ProxyListResp>('/api/store/proxies')
|
||||
|
||||
33
web/frpc/src/assets/css/_form-layout.scss
Normal file
33
web/frpc/src/assets/css/_form-layout.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
@use './mixins' as *;
|
||||
|
||||
/* Shared form layout styles for proxy/visitor form sections */
|
||||
.field-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.field-row.two-col {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.field-row.three-col {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.field-grow {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.switch-field :deep(.el-form-item__content) {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.field-row.two-col,
|
||||
.field-row.three-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
2
web/frpc/src/assets/css/_index.scss
Normal file
2
web/frpc/src/assets/css/_index.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@forward './variables';
|
||||
@forward './mixins';
|
||||
49
web/frpc/src/assets/css/_mixins.scss
Normal file
49
web/frpc/src/assets/css/_mixins.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
@use './variables' as vars;
|
||||
|
||||
@mixin mobile {
|
||||
@media (max-width: #{vars.$breakpoint-mobile - 1px}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@mixin page-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: vars.$spacing-xl 40px;
|
||||
|
||||
> * {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
padding: vars.$spacing-xl;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin custom-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d1d1d1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
61
web/frpc/src/assets/css/_variables.scss
Normal file
61
web/frpc/src/assets/css/_variables.scss
Normal file
@@ -0,0 +1,61 @@
|
||||
// Typography
|
||||
$font-size-xs: 11px;
|
||||
$font-size-sm: 13px;
|
||||
$font-size-md: 14px;
|
||||
$font-size-lg: 15px;
|
||||
$font-size-xl: 18px;
|
||||
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
|
||||
// Colors - Text
|
||||
$color-text-primary: var(--color-text-primary);
|
||||
$color-text-secondary: var(--color-text-secondary);
|
||||
$color-text-muted: var(--color-text-muted);
|
||||
$color-text-light: var(--color-text-light);
|
||||
|
||||
// Colors - Background
|
||||
$color-bg-primary: var(--color-bg-primary);
|
||||
$color-bg-secondary: var(--color-bg-secondary);
|
||||
$color-bg-tertiary: var(--color-bg-tertiary);
|
||||
$color-bg-muted: var(--color-bg-muted);
|
||||
$color-bg-hover: var(--color-bg-hover);
|
||||
$color-bg-active: var(--color-bg-active);
|
||||
|
||||
// Colors - Border
|
||||
$color-border: var(--color-border);
|
||||
$color-border-light: var(--color-border-light);
|
||||
$color-border-lighter: var(--color-border-lighter);
|
||||
|
||||
// Colors - Status
|
||||
$color-primary: var(--color-primary);
|
||||
$color-danger: var(--color-danger);
|
||||
$color-danger-dark: var(--color-danger-dark);
|
||||
$color-danger-light: var(--color-danger-light);
|
||||
|
||||
// Colors - Button
|
||||
$color-btn-primary: var(--color-btn-primary);
|
||||
$color-btn-primary-hover: var(--color-btn-primary-hover);
|
||||
|
||||
// Spacing
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 12px;
|
||||
$spacing-lg: 16px;
|
||||
$spacing-xl: 20px;
|
||||
|
||||
// Border Radius
|
||||
$radius-sm: 6px;
|
||||
$radius-md: 8px;
|
||||
|
||||
// Transitions
|
||||
$transition-fast: 0.15s ease;
|
||||
$transition-medium: 0.2s ease;
|
||||
|
||||
// Layout
|
||||
$header-height: 50px;
|
||||
$sidebar-width: 200px;
|
||||
|
||||
// Breakpoints
|
||||
$breakpoint-mobile: 768px;
|
||||
@@ -1,105 +0,0 @@
|
||||
/* Modern Base Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Smooth transitions for Element Plus components */
|
||||
.el-button,
|
||||
.el-card,
|
||||
.el-input,
|
||||
.el-select,
|
||||
.el-tag {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Card hover effects */
|
||||
.el-card:hover {
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Better scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Better form layouts */
|
||||
.el-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.el-row {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-col {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input enhancements */
|
||||
.el-input__wrapper {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
|
||||
}
|
||||
|
||||
/* Button enhancements */
|
||||
.el-button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tag enhancements */
|
||||
.el-tag {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Card enhancements */
|
||||
.el-card__header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Table enhancements */
|
||||
.el-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.el-table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.el-empty__description {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.el-loading-mask {
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -1,48 +1,51 @@
|
||||
/* Dark Mode Theme */
|
||||
/* Dark mode styles */
|
||||
html.dark {
|
||||
--el-bg-color: #1e1e2e;
|
||||
--el-bg-color-page: #1a1a2e;
|
||||
--el-bg-color-overlay: #27293d;
|
||||
--el-fill-color-blank: #1e1e2e;
|
||||
background-color: #1a1a2e;
|
||||
--el-bg-color: #212121;
|
||||
--el-bg-color-page: #181818;
|
||||
--el-bg-color-overlay: #303030;
|
||||
--el-fill-color-blank: #212121;
|
||||
--el-border-color: #404040;
|
||||
--el-border-color-light: #353535;
|
||||
--el-border-color-lighter: #2a2a2a;
|
||||
--el-text-color-primary: #e5e7eb;
|
||||
--el-text-color-secondary: #888888;
|
||||
--el-text-color-placeholder: #afafaf;
|
||||
background-color: #212121;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-color: #1a1a2e;
|
||||
color: #e5e7eb;
|
||||
/* Scrollbar */
|
||||
html.dark ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
html.dark ::-webkit-scrollbar-track {
|
||||
background: #27293d;
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
html.dark ::-webkit-scrollbar-thumb {
|
||||
background: #3a3d5c;
|
||||
background: #404040;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4d6c;
|
||||
background: #505050;
|
||||
}
|
||||
|
||||
/* Dark mode cards */
|
||||
html.dark .el-card {
|
||||
background-color: #27293d;
|
||||
border-color: #3a3d5c;
|
||||
/* Form */
|
||||
html.dark .el-form-item__label {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-card__header {
|
||||
border-bottom-color: #3a3d5c;
|
||||
}
|
||||
|
||||
/* Dark mode inputs */
|
||||
/* Input */
|
||||
html.dark .el-input__wrapper {
|
||||
background-color: #27293d;
|
||||
box-shadow: 0 0 0 1px #3a3d5c inset;
|
||||
background: var(--color-bg-input);
|
||||
box-shadow: 0 0 0 1px #404040 inset;
|
||||
}
|
||||
|
||||
html.dark .el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #4a4d6c inset;
|
||||
box-shadow: 0 0 0 1px #505050 inset;
|
||||
}
|
||||
|
||||
html.dark .el-input__wrapper.is-focus {
|
||||
@@ -54,71 +57,44 @@ html.dark .el-input__inner {
|
||||
}
|
||||
|
||||
html.dark .el-input__inner::placeholder {
|
||||
color: #6b7280;
|
||||
color: #afafaf;
|
||||
}
|
||||
|
||||
/* Dark mode textarea */
|
||||
html.dark .el-textarea__inner {
|
||||
background-color: #1e1e2d;
|
||||
border-color: #3a3d5c;
|
||||
background: var(--color-bg-input);
|
||||
box-shadow: 0 0 0 1px #404040 inset;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-textarea__inner::placeholder {
|
||||
color: #6b7280;
|
||||
html.dark .el-textarea__inner:hover {
|
||||
box-shadow: 0 0 0 1px #505050 inset;
|
||||
}
|
||||
|
||||
/* Dark mode table */
|
||||
html.dark .el-table {
|
||||
background-color: #27293d;
|
||||
html.dark .el-textarea__inner:focus {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
|
||||
/* Select */
|
||||
html.dark .el-select__wrapper {
|
||||
background: var(--color-bg-input);
|
||||
box-shadow: 0 0 0 1px #404040 inset;
|
||||
}
|
||||
|
||||
html.dark .el-select__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #505050 inset;
|
||||
}
|
||||
|
||||
html.dark .el-select__selected-item {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-table th.el-table__cell {
|
||||
background-color: #1e1e2e;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-table tr {
|
||||
background-color: #27293d;
|
||||
}
|
||||
|
||||
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background-color: #1e1e2e;
|
||||
}
|
||||
|
||||
html.dark .el-table__row:hover > td.el-table__cell {
|
||||
background-color: #2a2a3c !important;
|
||||
}
|
||||
|
||||
/* Dark mode tags */
|
||||
html.dark .el-tag--info {
|
||||
background-color: #3a3d5c;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark mode buttons */
|
||||
html.dark .el-button--default {
|
||||
background-color: #27293d;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-button--default:hover {
|
||||
background-color: #2a2a3c;
|
||||
border-color: #4a4d6c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Dark mode select */
|
||||
html.dark .el-select .el-input__wrapper {
|
||||
background-color: #27293d;
|
||||
html.dark .el-select__placeholder {
|
||||
color: #afafaf;
|
||||
}
|
||||
|
||||
html.dark .el-select-dropdown {
|
||||
background-color: #27293d;
|
||||
border-color: #3a3d5c;
|
||||
background: #303030;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
html.dark .el-select-dropdown__item {
|
||||
@@ -126,55 +102,92 @@ html.dark .el-select-dropdown__item {
|
||||
}
|
||||
|
||||
html.dark .el-select-dropdown__item:hover {
|
||||
background-color: #2a2a3c;
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
/* Dark mode dialog */
|
||||
html.dark .el-select-dropdown__item.is-selected {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
html.dark .el-select-dropdown__item.is-disabled {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
/* Tag */
|
||||
html.dark .el-tag--info {
|
||||
background: #303030;
|
||||
border-color: #404040;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
html.dark .el-button--default {
|
||||
background: #303030;
|
||||
border-color: #404040;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-button--default:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #505050;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
html.dark .el-card {
|
||||
background: #212121;
|
||||
border-color: #353535;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
html.dark .el-card__header {
|
||||
border-bottom-color: #353535;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
html.dark .el-dialog {
|
||||
background-color: #27293d;
|
||||
}
|
||||
|
||||
html.dark .el-dialog__header {
|
||||
border-bottom-color: #3a3d5c;
|
||||
background: #212121;
|
||||
}
|
||||
|
||||
html.dark .el-dialog__title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-dialog__body {
|
||||
color: #e5e7eb;
|
||||
/* Message */
|
||||
html.dark .el-message {
|
||||
background: #303030;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
/* Dark mode message box */
|
||||
html.dark .el-message-box {
|
||||
background-color: #27293d;
|
||||
border-color: #3a3d5c;
|
||||
html.dark .el-message--success {
|
||||
background: #1e3d2e;
|
||||
border-color: #3d6b4f;
|
||||
}
|
||||
|
||||
html.dark .el-message-box__title {
|
||||
color: #e5e7eb;
|
||||
html.dark .el-message--warning {
|
||||
background: #3d3020;
|
||||
border-color: #6b5020;
|
||||
}
|
||||
|
||||
html.dark .el-message-box__message {
|
||||
color: #e5e7eb;
|
||||
html.dark .el-message--error {
|
||||
background: #3d2027;
|
||||
border-color: #5c2d2d;
|
||||
}
|
||||
|
||||
/* Dark mode empty */
|
||||
html.dark .el-empty__description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Dark mode loading */
|
||||
/* Loading */
|
||||
html.dark .el-loading-mask {
|
||||
background-color: rgba(30, 30, 46, 0.9);
|
||||
background-color: rgba(33, 33, 33, 0.9);
|
||||
}
|
||||
|
||||
html.dark .el-loading-text {
|
||||
color: #e5e7eb;
|
||||
/* Overlay */
|
||||
html.dark .el-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Dark mode tooltip */
|
||||
html.dark .el-tooltip__trigger {
|
||||
color: #e5e7eb;
|
||||
/* Tooltip */
|
||||
html.dark .el-tooltip__popper {
|
||||
background: #303030 !important;
|
||||
border-color: #404040 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
117
web/frpc/src/assets/css/var.css
Normal file
117
web/frpc/src/assets/css/var.css
Normal file
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
/* Text colors */
|
||||
--color-text-primary: #303133;
|
||||
--color-text-secondary: #606266;
|
||||
--color-text-muted: #909399;
|
||||
--color-text-light: #c0c4cc;
|
||||
--color-text-placeholder: #a8abb2;
|
||||
|
||||
/* Background colors */
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #f9f9f9;
|
||||
--color-bg-tertiary: #fafafa;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-muted: #f4f4f5;
|
||||
--color-bg-input: #ffffff;
|
||||
--color-bg-hover: #efefef;
|
||||
--color-bg-active: #eaeaea;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #dcdfe6;
|
||||
--color-border-light: #e4e7ed;
|
||||
--color-border-lighter: #ebeef5;
|
||||
--color-border-extra-light: #f2f6fc;
|
||||
|
||||
/* Status colors */
|
||||
--color-primary: #409eff;
|
||||
--color-primary-light: #ecf5ff;
|
||||
--color-success: #67c23a;
|
||||
--color-warning: #e6a23c;
|
||||
--color-danger: #f56c6c;
|
||||
--color-danger-dark: #c45656;
|
||||
--color-danger-light: #fef0f0;
|
||||
--color-info: #909399;
|
||||
|
||||
/* Button colors */
|
||||
--color-btn-primary: #303133;
|
||||
--color-btn-primary-hover: #4a4d5c;
|
||||
|
||||
/* Element Plus mapping */
|
||||
--el-color-primary: var(--color-primary);
|
||||
--el-color-success: var(--color-success);
|
||||
--el-color-warning: var(--color-warning);
|
||||
--el-color-danger: var(--color-danger);
|
||||
--el-color-info: var(--color-info);
|
||||
|
||||
--el-text-color-primary: var(--color-text-primary);
|
||||
--el-text-color-regular: var(--color-text-secondary);
|
||||
--el-text-color-secondary: var(--color-text-muted);
|
||||
--el-text-color-placeholder: var(--color-text-placeholder);
|
||||
|
||||
--el-bg-color: var(--color-bg-primary);
|
||||
--el-bg-color-page: var(--color-bg-secondary);
|
||||
--el-bg-color-overlay: var(--color-bg-primary);
|
||||
|
||||
--el-border-color: var(--color-border);
|
||||
--el-border-color-light: var(--color-border-light);
|
||||
--el-border-color-lighter: var(--color-border-lighter);
|
||||
--el-border-color-extra-light: var(--color-border-extra-light);
|
||||
|
||||
--el-fill-color-blank: var(--color-bg-primary);
|
||||
--el-fill-color-light: var(--color-bg-tertiary);
|
||||
--el-fill-color: var(--color-bg-tertiary);
|
||||
--el-fill-color-dark: var(--color-bg-hover);
|
||||
--el-fill-color-darker: var(--color-bg-active);
|
||||
|
||||
/* Input */
|
||||
--el-input-bg-color: var(--color-bg-input);
|
||||
--el-input-border-color: var(--color-border);
|
||||
--el-input-hover-border-color: var(--color-border-light);
|
||||
|
||||
/* Dialog */
|
||||
--el-dialog-bg-color: var(--color-bg-primary);
|
||||
--el-overlay-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
/* Text colors */
|
||||
--color-text-primary: #e5e7eb;
|
||||
--color-text-secondary: #b0b0b0;
|
||||
--color-text-muted: #888888;
|
||||
--color-text-light: #666666;
|
||||
--color-text-placeholder: #afafaf;
|
||||
|
||||
/* Background colors */
|
||||
--color-bg-primary: #212121;
|
||||
--color-bg-secondary: #181818;
|
||||
--color-bg-tertiary: #303030;
|
||||
--color-bg-surface: #303030;
|
||||
--color-bg-muted: #303030;
|
||||
--color-bg-input: #2f2f2f;
|
||||
--color-bg-hover: #3a3a3a;
|
||||
--color-bg-active: #454545;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #404040;
|
||||
--color-border-light: #353535;
|
||||
--color-border-lighter: #2a2a2a;
|
||||
--color-border-extra-light: #222222;
|
||||
|
||||
/* Status colors */
|
||||
--color-primary: #409eff;
|
||||
--color-danger: #f87171;
|
||||
--color-danger-dark: #f87171;
|
||||
--color-danger-light: #3d2027;
|
||||
--color-info: #888888;
|
||||
|
||||
/* Button colors */
|
||||
--color-btn-primary: #404040;
|
||||
--color-btn-primary-hover: #505050;
|
||||
|
||||
/* Dark overrides */
|
||||
--el-text-color-regular: var(--color-text-primary);
|
||||
--el-overlay-color: rgba(0, 0, 0, 0.7);
|
||||
|
||||
background-color: #181818;
|
||||
color-scheme: dark;
|
||||
}
|
||||
144
web/frpc/src/components/ActionButton.vue
Normal file
144
web/frpc/src/components/ActionButton.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="action-button"
|
||||
:class="[variant, size, { 'is-loading': loading, 'is-danger': danger }]"
|
||||
:disabled="disabled || loading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div v-if="loading" class="spinner"></div>
|
||||
<span v-if="loading && loadingText">{{ loadingText }}</span>
|
||||
<slot v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'outline'
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
loadingText?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'primary',
|
||||
size: 'medium',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
loadingText: '',
|
||||
danger: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!props.disabled && !props.loading) {
|
||||
emit('click', event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
border-radius: $radius-md;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
&.small {
|
||||
padding: 5px $spacing-md;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
&.large {
|
||||
padding: 10px $spacing-xl;
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: $color-btn-primary;
|
||||
border-color: $color-btn-primary;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $color-btn-primary-hover;
|
||||
border-color: $color-btn-primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: $color-bg-hover;
|
||||
border-color: $color-border-light;
|
||||
color: $color-text-primary;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $color-border;
|
||||
}
|
||||
}
|
||||
|
||||
&.outline {
|
||||
background: transparent;
|
||||
border-color: $color-border;
|
||||
color: $color-text-primary;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
&.primary {
|
||||
background: $color-danger;
|
||||
border-color: $color-danger;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $color-danger-dark;
|
||||
border-color: $color-danger-dark;
|
||||
}
|
||||
}
|
||||
|
||||
&.outline, &.secondary {
|
||||
color: $color-danger;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $color-danger;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
web/frpc/src/components/BaseDialog.vue
Normal file
142
web/frpc/src/components/BaseDialog.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
:width="dialogWidth"
|
||||
:destroy-on-close="destroyOnClose"
|
||||
:close-on-click-modal="closeOnClickModal"
|
||||
:close-on-press-escape="closeOnPressEscape"
|
||||
:append-to-body="appendToBody"
|
||||
:top="dialogTop"
|
||||
:fullscreen="isMobile"
|
||||
class="base-dialog"
|
||||
:class="{ 'mobile-dialog': isMobile }"
|
||||
>
|
||||
<slot />
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useResponsive } from '../composables/useResponsive'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
title: string
|
||||
width?: string
|
||||
destroyOnClose?: boolean
|
||||
closeOnClickModal?: boolean
|
||||
closeOnPressEscape?: boolean
|
||||
appendToBody?: boolean
|
||||
top?: string
|
||||
}>(),
|
||||
{
|
||||
width: '480px',
|
||||
destroyOnClose: true,
|
||||
closeOnClickModal: true,
|
||||
closeOnPressEscape: true,
|
||||
appendToBody: false,
|
||||
top: '15vh',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const { isMobile } = useResponsive()
|
||||
|
||||
const dialogWidth = computed(() => {
|
||||
if (isMobile.value) return '100%'
|
||||
return props.width
|
||||
})
|
||||
|
||||
const dialogTop = computed(() => {
|
||||
if (isMobile.value) return '0'
|
||||
return props.top
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.base-dialog.el-dialog {
|
||||
border-radius: 16px;
|
||||
|
||||
.el-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
min-height: 42px;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 1px;
|
||||
background: $color-border-lighter;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.el-dialog__headerbtn {
|
||||
position: static;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@include flex-center;
|
||||
border-radius: $radius-sm;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&.mobile-dialog {
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.el-dialog__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 8px 12px;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
249
web/frpc/src/components/ConfigField.vue
Normal file
249
web/frpc/src/components/ConfigField.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<!-- Edit mode: use el-form-item for validation -->
|
||||
<el-form-item v-if="!readonly" :label="label" :prop="prop" :class="($attrs.class as string)">
|
||||
<!-- text -->
|
||||
<el-input
|
||||
v-if="type === 'text'"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<!-- number -->
|
||||
<el-input
|
||||
v-else-if="type === 'number'"
|
||||
:model-value="modelValue != null ? String(modelValue) : ''"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@update:model-value="handleNumberInput($event)"
|
||||
/>
|
||||
<!-- switch -->
|
||||
<div v-else-if="type === 'switch'" class="config-field-switch-wrap">
|
||||
<el-switch
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
size="small"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<span v-if="tip" class="config-field-switch-tip">{{ tip }}</span>
|
||||
</div>
|
||||
<!-- select -->
|
||||
<PopoverMenu
|
||||
v-else-if="type === 'select'"
|
||||
:model-value="modelValue"
|
||||
:display-value="selectDisplayValue"
|
||||
:disabled="disabled"
|
||||
:width="selectWidth"
|
||||
selectable
|
||||
full-width
|
||||
filterable
|
||||
:filter-placeholder="placeholder || 'Select...'"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #default="{ filterText }">
|
||||
<PopoverMenuItem
|
||||
v-for="opt in filteredOptions(filterText)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</PopoverMenuItem>
|
||||
</template>
|
||||
</PopoverMenu>
|
||||
<!-- password -->
|
||||
<el-input
|
||||
v-else-if="type === 'password'"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
type="password"
|
||||
show-password
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<!-- kv -->
|
||||
<KeyValueEditor
|
||||
v-else-if="type === 'kv'"
|
||||
:model-value="modelValue"
|
||||
:key-placeholder="keyPlaceholder"
|
||||
:value-placeholder="valuePlaceholder"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<!-- tags (string array) -->
|
||||
<StringListEditor
|
||||
v-else-if="type === 'tags'"
|
||||
:model-value="modelValue || []"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<div v-if="tip && type !== 'switch'" class="config-field-tip">{{ tip }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Readonly mode: plain display -->
|
||||
<div v-else class="config-field-readonly" :class="($attrs.class as string)">
|
||||
<div class="config-field-label">{{ label }}</div>
|
||||
<!-- switch readonly -->
|
||||
<el-switch
|
||||
v-if="type === 'switch'"
|
||||
:model-value="modelValue"
|
||||
disabled
|
||||
size="small"
|
||||
/>
|
||||
<!-- kv readonly -->
|
||||
<KeyValueEditor
|
||||
v-else-if="type === 'kv'"
|
||||
:model-value="modelValue || []"
|
||||
:key-placeholder="keyPlaceholder"
|
||||
:value-placeholder="valuePlaceholder"
|
||||
readonly
|
||||
/>
|
||||
<!-- tags readonly -->
|
||||
<StringListEditor
|
||||
v-else-if="type === 'tags'"
|
||||
:model-value="modelValue || []"
|
||||
readonly
|
||||
/>
|
||||
<!-- text/number/select/password readonly -->
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="displayValue"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import KeyValueEditor from './KeyValueEditor.vue'
|
||||
import StringListEditor from './StringListEditor.vue'
|
||||
import PopoverMenu from './PopoverMenu.vue'
|
||||
import PopoverMenuItem from './PopoverMenuItem.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
type?: 'text' | 'number' | 'switch' | 'select' | 'password' | 'kv' | 'tags'
|
||||
readonly?: boolean
|
||||
modelValue?: any
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
tip?: string
|
||||
prop?: string
|
||||
options?: Array<{ label: string; value: string | number }>
|
||||
min?: number
|
||||
max?: number
|
||||
keyPlaceholder?: string
|
||||
valuePlaceholder?: string
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
readonly: false,
|
||||
modelValue: undefined,
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
tip: '',
|
||||
prop: '',
|
||||
options: () => [],
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
keyPlaceholder: 'Key',
|
||||
valuePlaceholder: 'Value',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: any]
|
||||
}>()
|
||||
|
||||
const handleNumberInput = (val: string) => {
|
||||
if (val === '') {
|
||||
emit('update:modelValue', undefined)
|
||||
return
|
||||
}
|
||||
const num = Number(val)
|
||||
if (!isNaN(num)) {
|
||||
let clamped = num
|
||||
if (props.min != null && clamped < props.min) clamped = props.min
|
||||
if (props.max != null && clamped > props.max) clamped = props.max
|
||||
emit('update:modelValue', clamped)
|
||||
}
|
||||
}
|
||||
|
||||
const selectDisplayValue = computed(() => {
|
||||
const opt = props.options.find((o) => o.value === props.modelValue)
|
||||
return opt ? opt.label : ''
|
||||
})
|
||||
|
||||
const selectWidth = computed(() => {
|
||||
return Math.max(160, ...props.options.map((o) => o.label.length * 10 + 60))
|
||||
})
|
||||
|
||||
const filteredOptions = (filterText: string) => {
|
||||
if (!filterText) return props.options
|
||||
const lower = filterText.toLowerCase()
|
||||
return props.options.filter((o) => o.label.toLowerCase().includes(lower))
|
||||
}
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (props.modelValue == null || props.modelValue === '') return '—'
|
||||
if (props.type === 'select') {
|
||||
const opt = props.options.find((o) => o.value === props.modelValue)
|
||||
return opt ? opt.label : String(props.modelValue)
|
||||
}
|
||||
if (props.type === 'password') {
|
||||
return props.modelValue ? '••••••' : '—'
|
||||
}
|
||||
return String(props.modelValue)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-field-switch-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-field-switch-tip {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.config-field-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.config-field-readonly {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config-field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.config-field-readonly :deep(*) {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.config-field-readonly :deep(.el-input.is-disabled .el-input__wrapper) {
|
||||
background: var(--color-bg-tertiary);
|
||||
box-shadow: 0 0 0 1px var(--color-border-lighter) inset;
|
||||
}
|
||||
|
||||
.config-field-readonly :deep(.el-input.is-disabled .el-input__inner) {
|
||||
color: var(--color-text-primary);
|
||||
-webkit-text-fill-color: var(--color-text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.config-field-readonly :deep(.el-switch.is-disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
185
web/frpc/src/components/ConfigSection.vue
Normal file
185
web/frpc/src/components/ConfigSection.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="config-section-card">
|
||||
<!-- Collapsible: header is a separate clickable area -->
|
||||
<template v-if="collapsible">
|
||||
<div
|
||||
v-if="title"
|
||||
class="section-header clickable"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<h3 class="section-title">{{ title }}</h3>
|
||||
<div class="section-header-right">
|
||||
<span v-if="readonly && !hasValue" class="not-configured-badge">
|
||||
Not configured
|
||||
</span>
|
||||
<el-icon v-if="canToggle" class="collapse-arrow" :class="{ expanded }">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-wrapper" :class="{ expanded }">
|
||||
<div class="collapse-inner">
|
||||
<div class="section-body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Non-collapsible: title and content in one area -->
|
||||
<template v-else>
|
||||
<div class="section-body">
|
||||
<h3 v-if="title" class="section-title section-title-inline">{{ title }}</h3>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
collapsible?: boolean
|
||||
readonly?: boolean
|
||||
hasValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
collapsible: false,
|
||||
readonly: false,
|
||||
hasValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const computeInitial = () => {
|
||||
if (!props.collapsible) return true
|
||||
return props.hasValue
|
||||
}
|
||||
|
||||
const expanded = ref(computeInitial())
|
||||
|
||||
// Only auto-expand when hasValue goes from false to true (async data loaded)
|
||||
// Never auto-collapse — don't override user interaction
|
||||
watch(
|
||||
() => props.hasValue,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal && !oldVal && props.collapsible) {
|
||||
expanded.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const canToggle = computed(() => {
|
||||
if (!props.collapsible) return false
|
||||
if (props.readonly && !props.hasValue) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const handleToggle = () => {
|
||||
if (canToggle.value) {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.config-section-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--color-border-lighter);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Collapsible header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.section-header.clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.section-header.clickable:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Inline title for non-collapsible sections */
|
||||
.section-title-inline {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.not-configured-badge {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light);
|
||||
background: var(--color-bg-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
transition: transform 0.3s;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.collapse-arrow.expanded {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
/* Grid-based collapse animation */
|
||||
.collapse-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
|
||||
.collapse-wrapper.expanded {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.collapse-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
padding: 20px 20px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-body :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-body :deep(.config-field-readonly) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.section-body {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
web/frpc/src/components/ConfirmDialog.vue
Normal file
84
web/frpc/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="400px"
|
||||
:close-on-click-modal="false"
|
||||
:append-to-body="true"
|
||||
>
|
||||
<p class="confirm-message">{{ message }}</p>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ActionButton variant="outline" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
:danger="danger"
|
||||
:loading="loading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseDialog from './BaseDialog.vue'
|
||||
import ActionButton from './ActionButton.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
danger: false,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.confirm-message {
|
||||
margin: 0;
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
</style>
|
||||
102
web/frpc/src/components/FilterDropdown.vue
Normal file
102
web/frpc/src/components/FilterDropdown.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<PopoverMenu
|
||||
:model-value="modelValue"
|
||||
:width="width"
|
||||
placement="bottom-start"
|
||||
selectable
|
||||
:display-value="displayLabel"
|
||||
@update:model-value="$emit('update:modelValue', $event as string)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button class="filter-trigger" :class="{ 'has-value': modelValue }" :style="minWidth && !isMobile ? { minWidth: minWidth + 'px' } : undefined">
|
||||
<span class="filter-label">{{ label }}:</span>
|
||||
<span class="filter-value">{{ displayLabel }}</span>
|
||||
<el-icon class="filter-arrow"><ArrowDown /></el-icon>
|
||||
</button>
|
||||
</template>
|
||||
<PopoverMenuItem value="">{{ allLabel }}</PopoverMenuItem>
|
||||
<PopoverMenuItem
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</PopoverMenuItem>
|
||||
</PopoverMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import PopoverMenu from './PopoverMenu.vue'
|
||||
import PopoverMenuItem from './PopoverMenuItem.vue'
|
||||
import { useResponsive } from '../composables/useResponsive'
|
||||
|
||||
const { isMobile } = useResponsive()
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
label: string
|
||||
options: Array<{ label: string; value: string }>
|
||||
allLabel?: string
|
||||
width?: number
|
||||
minWidth?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
allLabel: 'All',
|
||||
width: 150,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (!props.modelValue) return props.allLabel
|
||||
const found = props.options.find((o) => o.value === props.modelValue)
|
||||
return found ? found.label : props.modelValue
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: 7px 12px;
|
||||
background: $color-bg-primary;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
box-shadow: 0 0 0 1px $color-border-light inset;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
cursor: pointer;
|
||||
transition: box-shadow $transition-fast;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px $color-border inset;
|
||||
}
|
||||
|
||||
&.has-value .filter-value {
|
||||
color: $color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: $color-text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
color: $color-text-secondary;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.filter-arrow {
|
||||
font-size: 12px;
|
||||
color: $color-text-light;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +1,51 @@
|
||||
<template>
|
||||
<div class="kv-editor">
|
||||
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
|
||||
<el-input
|
||||
:model-value="entry.key"
|
||||
:placeholder="keyPlaceholder"
|
||||
class="kv-input"
|
||||
@update:model-value="updateEntry(index, 'key', $event)"
|
||||
/>
|
||||
<el-input
|
||||
:model-value="entry.value"
|
||||
:placeholder="valuePlaceholder"
|
||||
class="kv-input"
|
||||
@update:model-value="updateEntry(index, 'value', $event)"
|
||||
/>
|
||||
<button class="kv-remove-btn" @click="removeEntry(index)">
|
||||
<template v-if="readonly">
|
||||
<div v-if="modelValue.length === 0" class="kv-empty">—</div>
|
||||
<div v-for="(entry, index) in modelValue" :key="index" class="kv-readonly-row">
|
||||
<span class="kv-readonly-key">{{ entry.key }}</span>
|
||||
<span class="kv-readonly-value">{{ entry.value }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
|
||||
<el-input
|
||||
:model-value="entry.key"
|
||||
:placeholder="keyPlaceholder"
|
||||
class="kv-input"
|
||||
@update:model-value="updateEntry(index, 'key', $event)"
|
||||
/>
|
||||
<el-input
|
||||
:model-value="entry.value"
|
||||
:placeholder="valuePlaceholder"
|
||||
class="kv-input"
|
||||
@update:model-value="updateEntry(index, 'value', $event)"
|
||||
/>
|
||||
<button class="kv-remove-btn" @click="removeEntry(index)">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="kv-add-btn" @click="addEntry">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
||||
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button class="kv-add-btn" @click="addEntry">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,11 +59,13 @@ interface Props {
|
||||
modelValue: KVEntry[]
|
||||
keyPlaceholder?: string
|
||||
valuePlaceholder?: string
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
keyPlaceholder: 'Key',
|
||||
valuePlaceholder: 'Value',
|
||||
readonly: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -129,25 +140,45 @@ html.dark .kv-remove-btn:hover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.kv-add-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.kv-add-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.kv-empty {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kv-readonly-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kv-readonly-key {
|
||||
color: var(--el-text-color-secondary);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.kv-readonly-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
303
web/frpc/src/components/PopoverMenu.vue
Normal file
303
web/frpc/src/components/PopoverMenu.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div
|
||||
class="popover-menu-wrapper"
|
||||
:class="{ 'is-full-width': fullWidth }"
|
||||
ref="wrapperRef"
|
||||
>
|
||||
<el-popover
|
||||
:visible="isOpen"
|
||||
:placement="placement"
|
||||
trigger="click"
|
||||
:width="popoverWidth"
|
||||
popper-class="popover-menu-popper"
|
||||
:persistent="false"
|
||||
:hide-after="0"
|
||||
:offset="8"
|
||||
:show-arrow="false"
|
||||
>
|
||||
<template #reference>
|
||||
<div
|
||||
v-if="filterable"
|
||||
class="popover-trigger filterable-trigger"
|
||||
:class="{ 'show-clear': showClearIcon }"
|
||||
@click.stop
|
||||
@mouseenter="isHovering = true"
|
||||
@mouseleave="isHovering = false"
|
||||
>
|
||||
<el-input
|
||||
ref="filterInputRef"
|
||||
:model-value="inputValue"
|
||||
:placeholder="inputPlaceholder"
|
||||
:disabled="disabled"
|
||||
:readonly="!isOpen"
|
||||
@click="handleInputClick"
|
||||
@update:model-value="handleFilterInput"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon
|
||||
v-if="showClearIcon"
|
||||
class="clear-icon"
|
||||
@click.stop="handleClear"
|
||||
>
|
||||
<CircleClose />
|
||||
</el-icon>
|
||||
<el-icon v-else class="arrow-icon"><ArrowDown /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div v-else class="popover-trigger" @click.stop="toggle">
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="popover-menu-content">
|
||||
<slot :close="close" :filter-text="filterText" />
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// Module-level singleton for coordinating popover menus
|
||||
const popoverEventTarget = new EventTarget()
|
||||
const CLOSE_ALL_EVENT = 'close-all-popovers'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
provide,
|
||||
inject,
|
||||
watch,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from 'vue'
|
||||
import { formItemContextKey, ElInput } from 'element-plus'
|
||||
import { ArrowDown, CircleClose } from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
width?: number
|
||||
placement?:
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
modelValue?: string | number | null
|
||||
selectable?: boolean
|
||||
disabled?: boolean
|
||||
fullWidth?: boolean
|
||||
filterable?: boolean
|
||||
filterPlaceholder?: string
|
||||
displayValue?: string
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 160,
|
||||
placement: 'bottom-end',
|
||||
modelValue: null,
|
||||
selectable: false,
|
||||
disabled: false,
|
||||
fullWidth: false,
|
||||
filterable: false,
|
||||
filterPlaceholder: 'Search...',
|
||||
displayValue: '',
|
||||
clearable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | number | null): void
|
||||
(e: 'filter-change', text: string): void
|
||||
}>()
|
||||
|
||||
const elFormItem = inject(formItemContextKey, undefined)
|
||||
|
||||
const isOpen = ref(false)
|
||||
const wrapperRef = ref<HTMLElement | null>(null)
|
||||
const instanceId = Symbol()
|
||||
const filterText = ref('')
|
||||
const filterInputRef = ref<InstanceType<typeof ElInput> | null>(null)
|
||||
const isHovering = ref(false)
|
||||
const triggerWidth = ref(0)
|
||||
|
||||
const popoverWidth = computed(() => {
|
||||
if (props.filterable && triggerWidth.value > 0) {
|
||||
return Math.max(triggerWidth.value, props.width)
|
||||
}
|
||||
return props.width
|
||||
})
|
||||
|
||||
const updateTriggerWidth = () => {
|
||||
if (wrapperRef.value) {
|
||||
triggerWidth.value = wrapperRef.value.offsetWidth
|
||||
}
|
||||
}
|
||||
|
||||
const inputValue = computed(() => {
|
||||
if (isOpen.value) return filterText.value
|
||||
if (props.modelValue) return props.displayValue || ''
|
||||
return ''
|
||||
})
|
||||
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (isOpen.value) return props.filterPlaceholder
|
||||
if (!props.modelValue) return props.displayValue || props.filterPlaceholder
|
||||
return props.filterPlaceholder
|
||||
})
|
||||
|
||||
const showClearIcon = computed(() => {
|
||||
return (
|
||||
props.clearable && props.modelValue && isHovering.value && !props.disabled
|
||||
)
|
||||
})
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open && props.filterable) {
|
||||
filterText.value = ''
|
||||
emit('filter-change', '')
|
||||
}
|
||||
})
|
||||
|
||||
const handleInputClick = () => {
|
||||
if (props.disabled) return
|
||||
if (!isOpen.value) {
|
||||
updateTriggerWidth()
|
||||
popoverEventTarget.dispatchEvent(
|
||||
new CustomEvent(CLOSE_ALL_EVENT, { detail: instanceId }),
|
||||
)
|
||||
isOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterInput = (value: string) => {
|
||||
filterText.value = value
|
||||
emit('filter-change', value)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
emit('update:modelValue', '')
|
||||
filterText.value = ''
|
||||
emit('filter-change', '')
|
||||
elFormItem?.validate?.('change')
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return
|
||||
if (!isOpen.value) {
|
||||
popoverEventTarget.dispatchEvent(
|
||||
new CustomEvent(CLOSE_ALL_EVENT, { detail: instanceId }),
|
||||
)
|
||||
}
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const handleCloseAll = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
if (customEvent.detail !== instanceId) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const select = (value: string | number) => {
|
||||
emit('update:modelValue', value)
|
||||
if (props.filterable) {
|
||||
filterText.value = ''
|
||||
emit('filter-change', '')
|
||||
filterInputRef.value?.blur()
|
||||
}
|
||||
close()
|
||||
elFormItem?.validate?.('change')
|
||||
}
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (wrapperRef.value && !wrapperRef.value.contains(target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
popoverEventTarget.addEventListener(CLOSE_ALL_EVENT, handleCloseAll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
popoverEventTarget.removeEventListener(CLOSE_ALL_EVENT, handleCloseAll)
|
||||
})
|
||||
|
||||
provide('popoverMenu', {
|
||||
close,
|
||||
select,
|
||||
selectable: props.selectable,
|
||||
modelValue: () => props.modelValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.popover-menu-wrapper {
|
||||
display: inline-block;
|
||||
|
||||
&.is-full-width {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
.popover-trigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popover-trigger {
|
||||
display: inline-flex;
|
||||
|
||||
&.filterable-trigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.el-input__suffix) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: var(--el-text-color-placeholder);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
color: var(--el-text-color-placeholder);
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popover-menu-content {
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.popover-menu-popper {
|
||||
padding: 0 !important;
|
||||
border-radius: 12px !important;
|
||||
border: 1px solid $color-border-light !important;
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
125
web/frpc/src/components/PopoverMenuItem.vue
Normal file
125
web/frpc/src/components/PopoverMenuItem.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<button
|
||||
class="popover-menu-item"
|
||||
:class="{
|
||||
'is-danger': danger,
|
||||
'is-selected': isSelected,
|
||||
'is-disabled': disabled,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="item-content">
|
||||
<slot />
|
||||
</span>
|
||||
<el-icon v-if="isSelected" class="check-icon">
|
||||
<Check />
|
||||
</el-icon>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { Check } from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
danger?: boolean
|
||||
disabled?: boolean
|
||||
value?: string | number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
danger: false,
|
||||
disabled: false,
|
||||
value: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const popoverMenu = inject<{
|
||||
close: () => void
|
||||
select: (value: string | number) => void
|
||||
selectable: boolean
|
||||
modelValue: () => string | number | null
|
||||
}>('popoverMenu')
|
||||
|
||||
const isSelected = computed(() => {
|
||||
if (!popoverMenu?.selectable || props.value === undefined) return false
|
||||
return popoverMenu.modelValue() === props.value
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.disabled) return
|
||||
|
||||
if (popoverMenu?.selectable && props.value !== undefined) {
|
||||
popoverMenu.select(props.value)
|
||||
} else {
|
||||
emit('click')
|
||||
popoverMenu?.close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.popover-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: $color-text-secondary;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
color: $color-danger;
|
||||
|
||||
.item-content :deep(.el-icon) {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
background: $color-danger-light;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: $color-bg-hover;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: inherit;
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 16px;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 16px;
|
||||
color: $color-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +1,49 @@
|
||||
<template>
|
||||
<div
|
||||
class="proxy-card"
|
||||
:class="{ 'has-error': proxy.err, 'is-store': isStore }"
|
||||
>
|
||||
<div class="proxy-card" :class="{ 'has-error': proxy.err }" @click="$emit('click', proxy)">
|
||||
<div class="card-main">
|
||||
<div class="card-left">
|
||||
<div class="card-header">
|
||||
<span class="proxy-name">{{ proxy.name }}</span>
|
||||
<span class="type-tag" :class="`type-${proxy.type}`">{{
|
||||
proxy.type.toUpperCase()
|
||||
}}</span>
|
||||
<span v-if="isStore" class="source-tag">
|
||||
<svg
|
||||
class="store-icon"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2 4.5A1.5 1.5 0 013.5 3h9A1.5 1.5 0 0114 4.5v1a.5.5 0 01-.5.5h-11a.5.5 0 01-.5-.5v-1z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3 7v5.5A1.5 1.5 0 004.5 14h7a1.5 1.5 0 001.5-1.5V7H3zm4 2h2a.5.5 0 010 1H7a.5.5 0 010-1z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Store
|
||||
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
|
||||
<span class="status-pill" :class="statusClass">
|
||||
<span class="status-dot"></span>
|
||||
{{ proxy.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span v-if="proxy.local_addr" class="meta-item">
|
||||
<span class="meta-label">Local</span>
|
||||
<span class="meta-value code">{{ proxy.local_addr }}</span>
|
||||
</span>
|
||||
<span v-if="proxy.plugin" class="meta-item">
|
||||
<span class="meta-label">Plugin</span>
|
||||
<span class="meta-value code">{{ proxy.plugin }}</span>
|
||||
</span>
|
||||
<span v-if="proxy.remote_addr" class="meta-item">
|
||||
<span class="meta-label">Remote</span>
|
||||
<span class="meta-value code">{{ proxy.remote_addr }}</span>
|
||||
</span>
|
||||
<div class="card-address">
|
||||
<template v-if="proxy.remote_addr && localDisplay">
|
||||
{{ proxy.remote_addr }} → {{ localDisplay }}
|
||||
</template>
|
||||
<template v-else-if="proxy.remote_addr">{{ proxy.remote_addr }}</template>
|
||||
<template v-else-if="localDisplay">{{ localDisplay }}</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-right">
|
||||
<div v-if="proxy.err" class="error-info">
|
||||
<el-tooltip :content="proxy.err" placement="top" :show-after="300">
|
||||
<div class="error-badge">
|
||||
<el-icon class="error-icon"><Warning /></el-icon>
|
||||
<span class="error-text">Error</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="status-badge" :class="statusClass">
|
||||
<span class="status-dot"></span>
|
||||
{{ proxy.status }}
|
||||
</div>
|
||||
|
||||
<!-- Store actions -->
|
||||
<div v-if="isStore" class="card-actions">
|
||||
<button
|
||||
class="action-btn edit-btn"
|
||||
@click.stop="$emit('edit', proxy)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.293 1.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-9 9A1 1 0 015 14H3a1 1 0 01-1-1v-2a1 1 0 01.293-.707l9-9z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn delete-btn"
|
||||
@click.stop="$emit('delete', proxy)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span v-if="showSource" class="source-label">{{ displaySource }}</span>
|
||||
<div v-if="showActions" @click.stop>
|
||||
<PopoverMenu :width="120" placement="bottom-end">
|
||||
<template #trigger>
|
||||
<ActionButton variant="outline" size="small">
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</ActionButton>
|
||||
</template>
|
||||
<PopoverMenuItem v-if="proxy.status === 'disabled'" @click="$emit('toggle', proxy, true)">
|
||||
<el-icon><Open /></el-icon>
|
||||
Enable
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem v-else @click="$emit('toggle', proxy, false)">
|
||||
<el-icon><TurnOff /></el-icon>
|
||||
Disable
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem @click="$emit('edit', proxy)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
Edit
|
||||
</PopoverMenuItem>
|
||||
<PopoverMenuItem danger @click="$emit('delete', proxy)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
Delete
|
||||
</PopoverMenuItem>
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,21 +52,40 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Warning } from '@element-plus/icons-vue'
|
||||
import type { ProxyStatus } from '../types/proxy'
|
||||
import { MoreFilled, Edit, Delete, Open, TurnOff } from '@element-plus/icons-vue'
|
||||
import ActionButton from './ActionButton.vue'
|
||||
import PopoverMenu from './PopoverMenu.vue'
|
||||
import PopoverMenuItem from './PopoverMenuItem.vue'
|
||||
import type { ProxyStatus } from '../types'
|
||||
|
||||
interface Props {
|
||||
proxy: ProxyStatus
|
||||
showSource?: boolean
|
||||
showActions?: boolean
|
||||
deleting?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showSource: false,
|
||||
showActions: false,
|
||||
deleting: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
click: [proxy: ProxyStatus]
|
||||
edit: [proxy: ProxyStatus]
|
||||
delete: [proxy: ProxyStatus]
|
||||
toggle: [proxy: ProxyStatus, enabled: boolean]
|
||||
}>()
|
||||
|
||||
const isStore = computed(() => props.proxy.source === 'store')
|
||||
const displaySource = computed(() => {
|
||||
return props.proxy.source === 'store' ? 'store' : 'config'
|
||||
})
|
||||
|
||||
const localDisplay = computed(() => {
|
||||
if (props.proxy.plugin) return `plugin:${props.proxy.plugin}`
|
||||
return props.proxy.local_addr || ''
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.proxy.status) {
|
||||
@@ -129,53 +93,43 @@ const statusClass = computed(() => {
|
||||
return 'running'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'disabled':
|
||||
return 'disabled'
|
||||
default:
|
||||
return 'waiting'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.proxy-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
background: $color-bg-primary;
|
||||
border: 1px solid $color-border-lighter;
|
||||
border-radius: $radius-md;
|
||||
padding: 14px 20px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-medium;
|
||||
|
||||
.proxy-card:hover {
|
||||
border-color: var(--el-border-color);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.06),
|
||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border-color: $color-border;
|
||||
}
|
||||
|
||||
.proxy-card.has-error {
|
||||
border-color: var(--el-color-danger-light-5);
|
||||
}
|
||||
|
||||
html.dark .proxy-card.has-error {
|
||||
border-color: var(--el-color-danger-dark-2);
|
||||
&.has-error {
|
||||
border-color: rgba(245, 108, 108, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 20px;
|
||||
gap: 20px;
|
||||
min-height: 76px;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
/* Left Section */
|
||||
.card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
@include flex-column;
|
||||
gap: $spacing-sm;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -183,311 +137,68 @@ html.dark .proxy-card.has-error {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.proxy-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: $color-bg-muted;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.type-tag.type-tcp {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
.type-tag.type-udp {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.type-tag.type-http {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
.type-tag.type-https {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #059669;
|
||||
}
|
||||
.type-tag.type-stcp,
|
||||
.type-tag.type-sudp,
|
||||
.type-tag.type-xtcp {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
.type-tag.type-tcpmux {
|
||||
background: rgba(236, 72, 153, 0.1);
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
html.dark .type-tag.type-tcp {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
html.dark .type-tag.type-udp {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
html.dark .type-tag.type-http {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #34d399;
|
||||
}
|
||||
html.dark .type-tag.type-https {
|
||||
background: rgba(52, 211, 153, 0.2);
|
||||
color: #34d399;
|
||||
}
|
||||
html.dark .type-tag.type-stcp,
|
||||
html.dark .type-tag.type-sudp,
|
||||
html.dark .type-tag.type-xtcp {
|
||||
background: rgba(167, 139, 250, 0.15);
|
||||
color: #a78bfa;
|
||||
}
|
||||
html.dark .type-tag.type-tcpmux {
|
||||
background: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(102, 126, 234, 0.1) 0%,
|
||||
rgba(118, 75, 162, 0.1) 100%
|
||||
);
|
||||
color: #764ba2;
|
||||
}
|
||||
|
||||
html.dark .source-tag {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(129, 140, 248, 0.15) 0%,
|
||||
rgba(167, 139, 250, 0.15) 100%
|
||||
);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.store-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
.card-address {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.meta-value.code {
|
||||
font-family:
|
||||
'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Right Section */
|
||||
.card-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: $spacing-md;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-danger-light-9);
|
||||
cursor: help;
|
||||
.source-label {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-light;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--el-color-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.status-badge.running .status-dot {
|
||||
background: var(--el-color-success);
|
||||
box-shadow: 0 0 0 2px var(--el-color-success-light-7);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.status-badge.error .status-dot {
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.status-badge.waiting {
|
||||
background: var(--el-color-warning-light-9);
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
.status-badge.waiting .status-dot {
|
||||
background: var(--el-color-warning);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.proxy-card.is-store:hover .status-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.proxy-card:hover .card-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
html.dark .edit-btn:hover {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
html.dark .delete-btn:hover {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
@include mobile {
|
||||
.card-main {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.card-address {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<template>
|
||||
<el-card
|
||||
class="stat-card"
|
||||
:class="{ clickable: !!to }"
|
||||
:body-style="{ padding: '20px' }"
|
||||
shadow="hover"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="stat-card-content">
|
||||
<div class="stat-icon" :class="`icon-${type}`">
|
||||
<component :is="iconComponent" class="icon" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ value }}</div>
|
||||
<div class="stat-label">{{ label }}</div>
|
||||
</div>
|
||||
<el-icon v-if="to" class="arrow-icon"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
<div v-if="subtitle" class="stat-subtitle">{{ subtitle }}</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Warning,
|
||||
Setting,
|
||||
ArrowRight,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string | number
|
||||
type?: 'proxies' | 'running' | 'error' | 'config'
|
||||
subtitle?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'proxies',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'proxies':
|
||||
return Connection
|
||||
case 'running':
|
||||
return CircleCheck
|
||||
case 'error':
|
||||
return Warning
|
||||
case 'config':
|
||||
return Setting
|
||||
default:
|
||||
return Connection
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.to) {
|
||||
router.push(props.to)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.stat-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card.clickable:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card.clickable:hover .arrow-icon {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
html.dark .stat-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.stat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: #909399;
|
||||
font-size: 18px;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html.dark .arrow-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon .icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.icon-proxies {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-running {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-error {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-config {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
html.dark .icon-proxies {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-running {
|
||||
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-error {
|
||||
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-config {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
html.dark .stat-value {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
html.dark .stat-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
html.dark .stat-subtitle {
|
||||
border-top-color: #3a3d5c;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
103
web/frpc/src/components/StatusPills.vue
Normal file
103
web/frpc/src/components/StatusPills.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="status-pills">
|
||||
<button
|
||||
v-for="pill in pills"
|
||||
:key="pill.status"
|
||||
class="pill"
|
||||
:class="{ active: modelValue === pill.status, [pill.status || 'all']: true }"
|
||||
@click="emit('update:modelValue', pill.status)"
|
||||
>
|
||||
{{ pill.label }} {{ pill.count }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
items: Array<{ status: string }>
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const pills = computed(() => {
|
||||
const counts = { running: 0, error: 0, waiting: 0 }
|
||||
for (const item of props.items) {
|
||||
const s = item.status as keyof typeof counts
|
||||
if (s in counts) {
|
||||
counts[s]++
|
||||
}
|
||||
}
|
||||
return [
|
||||
{ status: '', label: 'All', count: props.items.length },
|
||||
{ status: 'running', label: 'Running', count: counts.running },
|
||||
{ status: 'error', label: 'Error', count: counts.error },
|
||||
{ status: 'waiting', label: 'Waiting', count: counts.waiting },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-pills {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
background: $color-bg-muted;
|
||||
color: $color-text-secondary;
|
||||
transition: all $transition-fast;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.active {
|
||||
&.all {
|
||||
background: $color-bg-muted;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
&.running {
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(245, 108, 108, 0.1);
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.waiting {
|
||||
background: rgba(230, 162, 60, 0.1);
|
||||
color: #e6a23c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.status-pills {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
141
web/frpc/src/components/StringListEditor.vue
Normal file
141
web/frpc/src/components/StringListEditor.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="string-list-editor">
|
||||
<template v-if="readonly">
|
||||
<div v-if="!modelValue || modelValue.length === 0" class="list-empty">—</div>
|
||||
<div v-for="(item, index) in modelValue" :key="index" class="list-readonly-item">
|
||||
{{ item }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(item, index) in modelValue" :key="index" class="item-row">
|
||||
<el-input
|
||||
:model-value="item"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="updateItem(index, $event)"
|
||||
/>
|
||||
<button class="item-remove" @click="removeItem(index)">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="list-add-btn" @click="addItem">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" fill="currentColor"/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[]
|
||||
placeholder?: string
|
||||
readonly?: boolean
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'Enter value',
|
||||
readonly: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
const addItem = () => {
|
||||
emit('update:modelValue', [...(props.modelValue || []), ''])
|
||||
}
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const newValue = [...props.modelValue]
|
||||
newValue.splice(index, 1)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
|
||||
const updateItem = (index: number, value: string) => {
|
||||
const newValue = [...props.modelValue]
|
||||
newValue[index] = value
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.string-list-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-row .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.item-remove svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.item-remove:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.list-add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.list-add-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.list-add-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.list-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-readonly-item {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
padding: 2px 0;
|
||||
}
|
||||
</style>
|
||||
40
web/frpc/src/components/proxy-form/ProxyAuthSection.vue
Normal file
40
web/frpc/src/components/proxy-form/ProxyAuthSection.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<ConfigSection title="Authentication" :readonly="readonly">
|
||||
<template v-if="['http', 'tcpmux'].includes(form.type)">
|
||||
<div class="field-row three-col">
|
||||
<ConfigField label="HTTP User" type="text" v-model="form.httpUser" :readonly="readonly" />
|
||||
<ConfigField label="HTTP Password" type="password" v-model="form.httpPassword" :readonly="readonly" />
|
||||
<ConfigField label="Route By HTTP User" type="text" v-model="form.routeByHTTPUser" :readonly="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="['stcp', 'sudp', 'xtcp'].includes(form.type)">
|
||||
<div class="field-row two-col">
|
||||
<ConfigField label="Secret Key" type="password" v-model="form.secretKey" prop="secretKey" :readonly="readonly" />
|
||||
<ConfigField label="Allow Users" type="tags" v-model="form.allowUsers" placeholder="username" :readonly="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
</ConfigSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ProxyFormData } from '../../types'
|
||||
import ConfigSection from '../ConfigSection.vue'
|
||||
import ConfigField from '../ConfigField.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: ProxyFormData
|
||||
readonly?: boolean
|
||||
}>(), { readonly: false })
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: ProxyFormData] }>()
|
||||
|
||||
const form = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/css/form-layout';
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user