Compare commits

...

2 Commits
v0.67.1 ... tmp

Author SHA1 Message Date
fatedier
aada7144d1 frpc: support store source config 2026-02-13 15:37:06 +08:00
fatedier
d0347325fc pkg/config: fix custom domain validation to prevent false matches with subdomain host (#5178) 2026-02-13 14:10:18 +08:00
54 changed files with 5632 additions and 327 deletions

13
.gitignore vendored
View File

@@ -7,18 +7,6 @@
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof
@@ -42,3 +30,4 @@ client.key
# AI
CLAUDE.md
.sisyphus/

View File

@@ -38,6 +38,20 @@ 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)
if svr.storeSource != nil {
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreProxy)).Methods(http.MethodPost)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreProxy)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreProxy)).Methods(http.MethodPut)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreProxy)).Methods(http.MethodDelete)
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreVisitors)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreVisitor)).Methods(http.MethodPost)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreVisitor)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreVisitor)).Methods(http.MethodPut)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreVisitor)).Methods(http.MethodDelete)
}
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
@@ -53,12 +67,14 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
func newAPIController(svr *Service) *api.Controller {
return api.NewController(api.ControllerParams{
GetProxyStatus: svr.getAllProxyStatus,
ServerAddr: svr.common.ServerAddr,
ConfigFilePath: svr.configFilePath,
UnsafeFeatures: svr.unsafeFeatures,
UpdateConfig: svr.UpdateAllConfigurer,
GracefulClose: svr.GracefulClose,
GetProxyStatus: svr.getAllProxyStatus,
ServerAddr: svr.common.ServerAddr,
ConfigFilePath: svr.configFilePath,
UnsafeFeatures: svr.unsafeFeatures,
UpdateConfig: svr.UpdateConfigSource,
ReloadFromSources: svr.reloadConfigFromSources,
GracefulClose: svr.GracefulClose,
StoreSource: svr.storeSource,
})
}

View File

@@ -16,6 +16,7 @@ package api
import (
"cmp"
"encoding/json"
"fmt"
"net"
"net/http"
@@ -26,6 +27,7 @@ import (
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
@@ -35,48 +37,48 @@ import (
// Controller handles HTTP API requests for frpc.
type Controller struct {
// getProxyStatus returns the current proxy status.
// Returns nil if the control connection is not established.
getProxyStatus func() []*proxy.WorkingStatus
// serverAddr is the frps server address for display.
serverAddr string
// configFilePath is the path to the configuration file.
configFilePath string
// unsafeFeatures is used for validation.
unsafeFeatures *security.UnsafeFeatures
// updateConfig updates proxy and visitor configurations.
updateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
// gracefulClose gracefully stops the service.
gracefulClose func(d time.Duration)
getProxyStatus func() []*proxy.WorkingStatus
serverAddr string
configFilePath string
unsafeFeatures *security.UnsafeFeatures
updateConfig func(common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
reloadFromSources func() error
gracefulClose func(d time.Duration)
storeSource *source.StoreSource
}
// ControllerParams contains parameters for creating an APIController.
type ControllerParams struct {
GetProxyStatus func() []*proxy.WorkingStatus
ServerAddr string
ConfigFilePath string
UnsafeFeatures *security.UnsafeFeatures
UpdateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
GracefulClose func(d time.Duration)
GetProxyStatus func() []*proxy.WorkingStatus
ServerAddr string
ConfigFilePath string
UnsafeFeatures *security.UnsafeFeatures
UpdateConfig func(common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
ReloadFromSources func() error
GracefulClose func(d time.Duration)
StoreSource *source.StoreSource
}
// NewController creates a new Controller.
func NewController(params ControllerParams) *Controller {
return &Controller{
getProxyStatus: params.GetProxyStatus,
serverAddr: params.ServerAddr,
configFilePath: params.ConfigFilePath,
unsafeFeatures: params.UnsafeFeatures,
updateConfig: params.UpdateConfig,
gracefulClose: params.GracefulClose,
getProxyStatus: params.GetProxyStatus,
serverAddr: params.ServerAddr,
configFilePath: params.ConfigFilePath,
unsafeFeatures: params.UnsafeFeatures,
updateConfig: params.UpdateConfig,
reloadFromSources: params.ReloadFromSources,
gracefulClose: params.GracefulClose,
storeSource: params.StoreSource,
}
}
func (c *Controller) reloadFromSourcesOrError() error {
if err := c.reloadFromSources(); err != nil {
return httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to apply config: %v", err))
}
return nil
}
// Reload handles GET /api/reload
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
strictConfigMode := false
@@ -85,18 +87,29 @@ func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
strictConfigMode, _ = strconv.ParseBool(strictStr)
}
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(c.configFilePath, strictConfigMode)
result, err := config.LoadClientConfigResult(c.configFilePath, strictConfigMode)
if err != nil {
log.Warnf("reload frpc proxy config error: %s", err.Error())
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, c.unsafeFeatures); err != nil {
proxyCfgs := result.Proxies
visitorCfgs := result.Visitors
proxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers(
result.Common,
proxyCfgs,
visitorCfgs,
)
proxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation)
visitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation)
if _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, c.unsafeFeatures); err != nil {
log.Warnf("reload frpc proxy config error: %s", err.Error())
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if err := c.updateConfig(proxyCfgs, visitorCfgs); err != nil {
if err := c.updateConfig(result.Common, proxyCfgs, visitorCfgs); err != nil {
log.Warnf("reload frpc proxy config error: %s", err.Error())
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
@@ -165,7 +178,6 @@ func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
return nil, nil
}
// buildProxyStatusResp creates a ProxyStatusResp from proxy.WorkingStatus
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
psr := ProxyStatusResp{
Name: status.Name,
@@ -185,5 +197,302 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStat
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
}
}
// Check if proxy is from store
if c.storeSource != nil {
if c.storeSource.GetProxy(status.Name) != nil {
psr.Source = "store"
}
}
return psr
}
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
proxies, err := c.storeSource.GetAllProxies()
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to list proxies: %v", err))
}
resp := ProxyListResp{Proxies: make([]ProxyConfig, 0, len(proxies))}
for _, p := range proxies {
cfg, err := proxyConfigurerToMap(p)
if err != nil {
continue
}
resp.Proxies = append(resp.Proxies, ProxyConfig{
Name: p.GetBaseConfig().Name,
Type: p.GetBaseConfig().Type,
Config: cfg,
})
}
return resp, nil
}
func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
p := c.storeSource.GetProxy(name)
if p == nil {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name))
}
cfg, err := proxyConfigurerToMap(p)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return ProxyConfig{
Name: p.GetBaseConfig().Name,
Type: p.GetBaseConfig().Type,
Config: cfg,
}, nil
}
func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) {
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedProxyConfig
if err := json.Unmarshal(body, &typed); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.ProxyConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required")
}
typed.Complete()
if err := validation.ValidateProxyConfigurerForClient(typed.ProxyConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
}
if err := c.storeSource.AddProxy(typed.ProxyConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusConflict, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: created proxy %q", typed.ProxyConfigurer.GetBaseConfig().Name)
return nil, nil
}
func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedProxyConfig
if err := json.Unmarshal(body, &typed); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.ProxyConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required")
}
bodyName := typed.ProxyConfigurer.GetBaseConfig().Name
if bodyName != name {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name in URL must match name in body")
}
typed.Complete()
if err := validation.ValidateProxyConfigurerForClient(typed.ProxyConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
}
if err := c.storeSource.UpdateProxy(typed.ProxyConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: updated proxy %q", name)
return nil, nil
}
func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
if err := c.storeSource.RemoveProxy(name); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: deleted proxy %q", name)
return nil, nil
}
func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) {
visitors, err := c.storeSource.GetAllVisitors()
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to list visitors: %v", err))
}
resp := VisitorListResp{Visitors: make([]VisitorConfig, 0, len(visitors))}
for _, v := range visitors {
cfg, err := visitorConfigurerToMap(v)
if err != nil {
continue
}
resp.Visitors = append(resp.Visitors, VisitorConfig{
Name: v.GetBaseConfig().Name,
Type: v.GetBaseConfig().Type,
Config: cfg,
})
}
return resp, nil
}
func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
v := c.storeSource.GetVisitor(name)
if v == nil {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name))
}
cfg, err := visitorConfigurerToMap(v)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return VisitorConfig{
Name: v.GetBaseConfig().Name,
Type: v.GetBaseConfig().Type,
Config: cfg,
}, nil
}
func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) {
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedVisitorConfig
if err := json.Unmarshal(body, &typed); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.VisitorConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required")
}
typed.Complete()
if err := validation.ValidateVisitorConfigurer(typed.VisitorConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
}
if err := c.storeSource.AddVisitor(typed.VisitorConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusConflict, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: created visitor %q", typed.VisitorConfigurer.GetBaseConfig().Name)
return nil, nil
}
func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedVisitorConfig
if err := json.Unmarshal(body, &typed); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.VisitorConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required")
}
bodyName := typed.VisitorConfigurer.GetBaseConfig().Name
if bodyName != name {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name in URL must match name in body")
}
typed.Complete()
if err := validation.ValidateVisitorConfigurer(typed.VisitorConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
}
if err := c.storeSource.UpdateVisitor(typed.VisitorConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: updated visitor %q", name)
return nil, nil
}
func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
if err := c.storeSource.RemoveVisitor(name); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: deleted visitor %q", name)
return nil, nil
}
func proxyConfigurerToMap(p v1.ProxyConfigurer) (map[string]any, error) {
data, err := json.Marshal(p)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return m, nil
}
func visitorConfigurerToMap(v v1.VisitorConfigurer) (map[string]any, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return m, nil
}

View File

@@ -26,4 +26,34 @@ type ProxyStatusResp struct {
LocalAddr string `json:"local_addr"`
Plugin string `json:"plugin"`
RemoteAddr string `json:"remote_addr"`
Source string `json:"source,omitempty"` // "store" or "config"
}
// ProxyConfig wraps proxy configuration for API requests/responses.
type ProxyConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
// VisitorConfig wraps visitor configuration for API requests/responses.
type VisitorConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
// ProxyListResp is the response for GET /api/store/proxies
type ProxyListResp struct {
Proxies []ProxyConfig `json:"proxies"`
}
// VisitorListResp is the response for GET /api/store/visitors
type VisitorListResp struct {
Visitors []VisitorConfig `json:"visitors"`
}
// ErrorResp represents an error response
type ErrorResp struct {
Error string `json:"error"`
}

View File

@@ -27,6 +27,7 @@ import (
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/util"
"github.com/fatedier/frp/pkg/util/wait"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -156,6 +157,8 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
return
}
startMsg.ProxyName = util.StripUserPrefix(ctl.sessionCtx.Common.User, startMsg.ProxyName)
// dispatch this work connection to related proxy
ctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg)
}
@@ -165,11 +168,12 @@ func (ctl *Control) handleNewProxyResp(m msg.Message) {
inMsg := m.(*msg.NewProxyResp)
// Server will return NewProxyResp message to each NewProxy message.
// Start a new proxy handler if no error got
err := ctl.pm.StartProxy(inMsg.ProxyName, inMsg.RemoteAddr, inMsg.Error)
proxyName := util.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
if err != nil {
xl.Warnf("[%s] start error: %v", inMsg.ProxyName, err)
xl.Warnf("[%s] start error: %v", proxyName, err)
} else {
xl.Infof("[%s] start proxy success", inMsg.ProxyName)
xl.Infof("[%s] start proxy success", proxyName)
}
}

View File

@@ -30,6 +30,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/pkg/util/util"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
)
@@ -86,6 +87,8 @@ type Wrapper struct {
xl *xlog.Logger
ctx context.Context
wireName string
}
func NewWrapper(
@@ -113,6 +116,7 @@ func NewWrapper(
vnetController: vnetController,
xl: xl,
ctx: xlog.NewContext(ctx, xl),
wireName: util.AddUserPrefix(clientCfg.User, baseInfo.Name),
}
if baseInfo.HealthCheck.Type != "" && baseInfo.LocalPort > 0 {
@@ -182,7 +186,7 @@ func (pw *Wrapper) Stop() {
func (pw *Wrapper) close() {
_ = pw.handler(&event.CloseProxyPayload{
CloseProxyMsg: &msg.CloseProxy{
ProxyName: pw.Name,
ProxyName: pw.wireName,
},
})
}
@@ -208,6 +212,7 @@ func (pw *Wrapper) checkWorker() {
var newProxyMsg msg.NewProxy
pw.Cfg.MarshalToMsg(&newProxyMsg)
newProxyMsg.ProxyName = pw.wireName
pw.lastSendStartMsg = now
_ = pw.handler(&event.StartProxyPayload{
NewProxyMsg: &newProxyMsg,

View File

@@ -30,6 +30,7 @@ import (
"github.com/fatedier/frp/pkg/nathole"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/util"
)
func init() {
@@ -85,7 +86,7 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
transactionID := nathole.NewTransactionID()
natHoleClientMsg := &msg.NatHoleClient{
TransactionID: transactionID,
ProxyName: pxy.cfg.Name,
ProxyName: util.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name),
Sid: natHoleSidMsg.Sid,
MappedAddrs: prepareResult.Addrs,
AssistedAddrs: prepareResult.AssistedAddrs,

View File

@@ -29,6 +29,8 @@ import (
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/auth"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/policy/security"
@@ -61,9 +63,11 @@ func (e cancelErr) Error() string {
// ServiceOptions contains options for creating a new client service.
type ServiceOptions struct {
Common *v1.ClientCommonConfig
ProxyCfgs []v1.ProxyConfigurer
VisitorCfgs []v1.VisitorConfigurer
Common *v1.ClientCommonConfig
// ConfigSourceAggregator manages internal config and optional store sources.
// It is required for creating a Service.
ConfigSourceAggregator *source.Aggregator
UnsafeFeatures *security.UnsafeFeatures
@@ -119,11 +123,20 @@ type Service struct {
vnetController *vnet.Controller
cfgMu sync.RWMutex
common *v1.ClientCommonConfig
proxyCfgs []v1.ProxyConfigurer
visitorCfgs []v1.VisitorConfigurer
clientSpec *msg.ClientSpec
cfgMu sync.RWMutex
common *v1.ClientCommonConfig
// reloadCommon is used for filtering/defaulting during config-source reloads.
// It can be updated by /api/reload without mutating startup-only common behavior.
reloadCommon *v1.ClientCommonConfig
proxyCfgs []v1.ProxyConfigurer
visitorCfgs []v1.VisitorConfigurer
clientSpec *msg.ClientSpec
// aggregator manages multiple configuration sources.
// When set, the service watches for config changes and reloads automatically.
aggregator *source.Aggregator
configSource *source.ConfigSource
storeSource *source.StoreSource
unsafeFeatures *security.UnsafeFeatures
@@ -160,19 +173,39 @@ func NewService(options ServiceOptions) (*Service, error) {
return nil, err
}
if options.ConfigSourceAggregator == nil {
return nil, fmt.Errorf("config source aggregator is required")
}
configSource := options.ConfigSourceAggregator.ConfigSource()
storeSource := options.ConfigSourceAggregator.StoreSource()
proxyCfgs, visitorCfgs, loadErr := options.ConfigSourceAggregator.Load()
if loadErr != nil {
return nil, fmt.Errorf("failed to load config from aggregator: %w", loadErr)
}
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(options.Common, proxyCfgs, visitorCfgs)
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
s := &Service{
ctx: context.Background(),
auth: authRuntime,
webServer: webServer,
common: options.Common,
reloadCommon: options.Common,
configFilePath: options.ConfigFilePath,
unsafeFeatures: options.UnsafeFeatures,
proxyCfgs: options.ProxyCfgs,
visitorCfgs: options.VisitorCfgs,
proxyCfgs: proxyCfgs,
visitorCfgs: visitorCfgs,
clientSpec: options.ClientSpec,
aggregator: options.ConfigSourceAggregator,
configSource: configSource,
storeSource: storeSource,
connectorCreator: options.ConnectorCreator,
handleWorkConnCb: options.HandleWorkConnCb,
}
if webServer != nil {
webServer.RouteRegister(s.registerRouteHandlers)
}
@@ -403,6 +436,33 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
return nil
}
func (svr *Service) UpdateConfigSource(
common *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
) error {
cfgSource := svr.configSource
if cfgSource == nil {
return fmt.Errorf("config source is not available")
}
// Update reloadCommon before ReplaceAll so the subsequent reload uses the
// same common config as /api/reload validation.
svr.cfgMu.Lock()
prevReloadCommon := svr.reloadCommon
svr.reloadCommon = common
svr.cfgMu.Unlock()
if err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
svr.cfgMu.Lock()
svr.reloadCommon = prevReloadCommon
svr.cfgMu.Unlock()
return err
}
return svr.reloadConfigFromSources()
}
func (svr *Service) Close() {
svr.GracefulClose(time.Duration(0))
}
@@ -423,6 +483,11 @@ func (svr *Service) stop() {
svr.webServer.Close()
svr.webServer = nil
}
if svr.aggregator != nil {
svr.aggregator = nil
}
svr.configSource = nil
svr.storeSource = nil
}
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
@@ -453,3 +518,28 @@ type statusExporterImpl struct {
func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) {
return s.getProxyStatusFunc(name)
}
func (svr *Service) reloadConfigFromSources() error {
if svr.aggregator == nil {
return errors.New("config aggregator is not initialized")
}
svr.cfgMu.RLock()
reloadCommon := svr.reloadCommon
svr.cfgMu.RUnlock()
proxies, visitors, err := svr.aggregator.Load()
if err != nil {
return fmt.Errorf("reload config from sources failed: %w", err)
}
proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)
proxies = config.CompleteProxyConfigurers(proxies)
visitors = config.CompleteVisitorConfigurers(visitors)
// Atomically replace the entire configuration
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
return err
}
return nil
}

View File

@@ -103,9 +103,10 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
defer visitorConn.Close()
now := time.Now().Unix()
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
newVisitorConnMsg := &msg.NewVisitorConn{
RunID: sv.helper.RunID(),
ProxyName: sv.cfg.ServerName,
ProxyName: targetProxyName,
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
Timestamp: now,
UseEncryption: sv.cfg.Transport.UseEncryption,

View File

@@ -205,9 +205,10 @@ func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) {
}
now := time.Now().Unix()
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
newVisitorConnMsg := &msg.NewVisitorConn{
RunID: sv.helper.RunID(),
ProxyName: sv.cfg.ServerName,
ProxyName: targetProxyName,
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
Timestamp: now,
UseEncryption: sv.cfg.Transport.UseEncryption,

View File

@@ -280,8 +280,9 @@ func (sv *XTCPVisitor) getTunnelConn(ctx context.Context) (net.Conn, error) {
// 4. Create a tunnel session using an underlying UDP connection.
func (sv *XTCPVisitor) makeNatHole() {
xl := xlog.FromContextSafe(sv.ctx)
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
xl.Tracef("makeNatHole start")
if err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), sv.cfg.ServerName, 5*time.Second); err != nil {
if err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), targetProxyName, 5*time.Second); err != nil {
xl.Warnf("nathole precheck error: %v", err)
return
}
@@ -310,7 +311,7 @@ func (sv *XTCPVisitor) makeNatHole() {
transactionID := nathole.NewTransactionID()
natHoleVisitorMsg := &msg.NatHoleVisitor{
TransactionID: transactionID,
ProxyName: sv.cfg.ServerName,
ProxyName: targetProxyName,
Protocol: sv.cfg.Protocol,
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
Timestamp: now,

View File

@@ -22,6 +22,7 @@ import (
"github.com/spf13/cobra"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
@@ -86,13 +87,14 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
os.Exit(1)
}
c.Complete(clientCfg.User)
c.GetBaseConfig().Type = name
if err := validation.ValidateProxyConfigurerForClient(c); err != nil {
c.Complete()
proxyCfg := c
if err := validation.ValidateProxyConfigurerForClient(proxyCfg); err != nil {
fmt.Println(err)
os.Exit(1)
}
err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, unsafeFeatures, "")
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -117,13 +119,14 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
os.Exit(1)
}
c.Complete(clientCfg)
c.GetBaseConfig().Type = name
if err := validation.ValidateVisitorConfigurer(c); err != nil {
c.Complete()
visitorCfg := c
if err := validation.ValidateVisitorConfigurer(visitorCfg); err != nil {
fmt.Println(err)
os.Exit(1)
}
err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, unsafeFeatures, "")
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -131,3 +134,18 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
},
}
}
func startService(
cfg *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures *security.UnsafeFeatures,
cfgFile string,
) error {
configSource := source.NewConfigSource()
if err := configSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
return fmt.Errorf("failed to set config source: %w", err)
}
aggregator := source.NewAggregator(configSource)
return startServiceWithAggregator(cfg, aggregator, unsafeFeatures, cfgFile)
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/fatedier/frp/client"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/featuregate"
@@ -120,22 +121,64 @@ func handleTermSignal(svr *client.Service) {
}
func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error {
cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode)
// Load configuration
result, err := config.LoadClientConfigResult(cfgFilePath, strictConfigMode)
if err != nil {
return err
}
if isLegacyFormat {
if result.IsLegacyFormat {
fmt.Printf("WARNING: ini format is deprecated and the support will be removed in the future, " +
"please use yaml/json/toml format instead!\n")
}
if len(cfg.FeatureGates) > 0 {
if err := featuregate.SetFromMap(cfg.FeatureGates); err != nil {
if len(result.Common.FeatureGates) > 0 {
if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil {
return err
}
}
warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
return runClientWithAggregator(result, unsafeFeatures, cfgFilePath)
}
// runClientWithAggregator runs the client using the internal source aggregator.
func runClientWithAggregator(result *config.ClientConfigLoadResult, unsafeFeatures *security.UnsafeFeatures, cfgFilePath string) error {
configSource := source.NewConfigSource()
if err := configSource.ReplaceAll(result.Proxies, result.Visitors); err != nil {
return fmt.Errorf("failed to set config source: %w", err)
}
var storeSource *source.StoreSource
if result.Common.Store.IsEnabled() {
storePath := result.Common.Store.Path
if storePath != "" && cfgFilePath != "" && !filepath.IsAbs(storePath) {
storePath = filepath.Join(filepath.Dir(cfgFilePath), storePath)
}
s, err := source.NewStoreSource(source.StoreSourceConfig{
Path: storePath,
})
if err != nil {
return fmt.Errorf("failed to create store source: %w", err)
}
storeSource = s
}
aggregator := source.NewAggregator(configSource)
if storeSource != nil {
aggregator.SetStoreSource(storeSource)
}
proxyCfgs, visitorCfgs, err := aggregator.Load()
if err != nil {
return fmt.Errorf("failed to load config from sources: %w", err)
}
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs)
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
warning, err := validation.ValidateAllClientConfig(result.Common, proxyCfgs, visitorCfgs, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
@@ -143,35 +186,32 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro
return err
}
return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, cfgFilePath)
return startServiceWithAggregator(result.Common, aggregator, unsafeFeatures, cfgFilePath)
}
func startService(
func startServiceWithAggregator(
cfg *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
aggregator *source.Aggregator,
unsafeFeatures *security.UnsafeFeatures,
cfgFile string,
) error {
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
if cfgFile != "" {
log.Infof("start frpc service for config file [%s]", cfgFile)
log.Infof("start frpc service for config file [%s] with aggregated configuration", cfgFile)
defer log.Infof("frpc service for config file [%s] stopped", cfgFile)
}
svr, err := client.NewService(client.ServiceOptions{
Common: cfg,
ProxyCfgs: proxyCfgs,
VisitorCfgs: visitorCfgs,
UnsafeFeatures: unsafeFeatures,
ConfigFilePath: cfgFile,
Common: cfg,
ConfigSourceAggregator: aggregator,
UnsafeFeatures: unsafeFeatures,
ConfigFilePath: cfgFile,
})
if err != nil {
return err
}
shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic"
// Capture the exit signal if we use kcp or quic.
if shouldGracefulClose {
go handleTermSignal(svr)
}

View File

@@ -180,7 +180,7 @@ func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.
}
configurer.UnmarshalFromMsg(m)
configurer.Complete("")
configurer.Complete()
if err := validation.ValidateProxyConfigurerForServer(configurer, serverCfg); err != nil {
return nil, err
@@ -219,60 +219,131 @@ func LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error)
return svrCfg, isLegacyFormat, nil
}
// ClientConfigLoadResult contains the result of loading a client configuration file.
type ClientConfigLoadResult struct {
// Common contains the common client configuration.
Common *v1.ClientCommonConfig
// Proxies contains proxy configurations from inline [[proxies]] and includeConfigFiles.
// These are NOT completed (user prefix not added).
Proxies []v1.ProxyConfigurer
// Visitors contains visitor configurations from inline [[visitors]] and includeConfigFiles.
// These are NOT completed.
Visitors []v1.VisitorConfigurer
// IsLegacyFormat indicates whether the config file is in legacy INI format.
IsLegacyFormat bool
}
// LoadClientConfigResult loads and parses a client configuration file.
// It returns the raw configuration without completing proxies/visitors.
// The caller should call Complete on the configs manually for legacy behavior.
func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult, error) {
result := &ClientConfigLoadResult{
Proxies: make([]v1.ProxyConfigurer, 0),
Visitors: make([]v1.VisitorConfigurer, 0),
}
if DetectLegacyINIFormatFromFile(path) {
legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)
if err != nil {
return nil, err
}
result.Common = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
for _, c := range legacyProxyCfgs {
result.Proxies = append(result.Proxies, legacy.Convert_ProxyConf_To_v1(c))
}
for _, c := range legacyVisitorCfgs {
result.Visitors = append(result.Visitors, legacy.Convert_VisitorConf_To_v1(c))
}
result.IsLegacyFormat = true
} else {
allCfg := v1.ClientConfig{}
if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {
return nil, err
}
result.Common = &allCfg.ClientCommonConfig
for _, c := range allCfg.Proxies {
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
}
for _, c := range allCfg.Visitors {
result.Visitors = append(result.Visitors, c.VisitorConfigurer)
}
}
// Load additional config from includes.
// legacy ini format already handle this in ParseClientConfig.
if len(result.Common.IncludeConfigFiles) > 0 && !result.IsLegacyFormat {
extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(result.Common.IncludeConfigFiles, result.IsLegacyFormat, strict)
if err != nil {
return nil, err
}
result.Proxies = append(result.Proxies, extProxyCfgs...)
result.Visitors = append(result.Visitors, extVisitorCfgs...)
}
// Complete the common config
if result.Common != nil {
if err := result.Common.Complete(); err != nil {
return nil, err
}
}
return result, nil
}
func LoadClientConfig(path string, strict bool) (
*v1.ClientCommonConfig,
[]v1.ProxyConfigurer,
[]v1.VisitorConfigurer,
bool, error,
) {
var (
cliCfg *v1.ClientCommonConfig
proxyCfgs = make([]v1.ProxyConfigurer, 0)
visitorCfgs = make([]v1.VisitorConfigurer, 0)
isLegacyFormat bool
)
if DetectLegacyINIFormatFromFile(path) {
legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)
if err != nil {
return nil, nil, nil, true, err
}
cliCfg = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
for _, c := range legacyProxyCfgs {
proxyCfgs = append(proxyCfgs, legacy.Convert_ProxyConf_To_v1(c))
}
for _, c := range legacyVisitorCfgs {
visitorCfgs = append(visitorCfgs, legacy.Convert_VisitorConf_To_v1(c))
}
isLegacyFormat = true
} else {
allCfg := v1.ClientConfig{}
if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {
return nil, nil, nil, false, err
}
cliCfg = &allCfg.ClientCommonConfig
for _, c := range allCfg.Proxies {
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
}
for _, c := range allCfg.Visitors {
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
}
result, err := LoadClientConfigResult(path, strict)
if err != nil {
return nil, nil, nil, result != nil && result.IsLegacyFormat, err
}
// Load additional config from includes.
// legacy ini format already handle this in ParseClientConfig.
if len(cliCfg.IncludeConfigFiles) > 0 && !isLegacyFormat {
extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict)
if err != nil {
return nil, nil, nil, isLegacyFormat, err
}
proxyCfgs = append(proxyCfgs, extProxyCfgs...)
visitorCfgs = append(visitorCfgs, extVisitorCfgs...)
proxyCfgs := result.Proxies
visitorCfgs := result.Visitors
proxyCfgs, visitorCfgs = FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs)
proxyCfgs = CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = CompleteVisitorConfigurers(visitorCfgs)
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
}
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
proxyCfgs := proxies
for _, c := range proxyCfgs {
c.Complete()
}
return proxyCfgs
}
func CompleteVisitorConfigurers(visitors []v1.VisitorConfigurer) []v1.VisitorConfigurer {
visitorCfgs := visitors
for _, c := range visitorCfgs {
c.Complete()
}
return visitorCfgs
}
func FilterClientConfigurers(
common *v1.ClientCommonConfig,
proxies []v1.ProxyConfigurer,
visitors []v1.VisitorConfigurer,
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
if common == nil {
common = &v1.ClientCommonConfig{}
}
proxyCfgs := proxies
visitorCfgs := visitors
// Filter by start
if len(cliCfg.Start) > 0 {
startSet := sets.New(cliCfg.Start...)
if len(common.Start) > 0 {
startSet := sets.New(common.Start...)
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {
return startSet.Has(c.GetBaseConfig().Name)
})
@@ -291,19 +362,7 @@ func LoadClientConfig(path string, strict bool) (
enabled := c.GetBaseConfig().Enabled
return enabled == nil || *enabled
})
if cliCfg != nil {
if err := cliCfg.Complete(); err != nil {
return nil, nil, nil, isLegacyFormat, err
}
}
for _, c := range proxyCfgs {
c.Complete(cliCfg.User)
}
for _, c := range visitorCfgs {
c.Complete(cliCfg)
}
return cliCfg, proxyCfgs, visitorCfgs, isLegacyFormat, nil
return proxyCfgs, visitorCfgs
}
func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {

View File

@@ -15,6 +15,7 @@
package config
import (
"encoding/json"
"fmt"
"strings"
"testing"
@@ -273,6 +274,169 @@ proxies:
require.Equal("stcp", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Type)
}
func TestFilterClientConfigurers_PreserveRawNamesAndNoMutation(t *testing.T) {
require := require.New(t)
enabled := true
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy-raw"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
proxyCfg.Enabled = &enabled
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor-raw"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server-raw"
visitorCfg.FallbackTo = "fallback-raw"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
visitorCfg.Enabled = &enabled
common := &v1.ClientCommonConfig{
User: "alice",
}
proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
require.Len(proxies, 1)
require.Len(visitors, 1)
p := proxies[0].GetBaseConfig()
require.Equal("proxy-raw", p.Name)
require.Empty(p.LocalIP)
v := visitors[0].GetBaseConfig()
require.Equal("visitor-raw", v.Name)
require.Equal("server-raw", v.ServerName)
require.Empty(v.BindAddr)
xtcp := visitors[0].(*v1.XTCPVisitorConfig)
require.Equal("fallback-raw", xtcp.FallbackTo)
require.Empty(xtcp.Protocol)
}
func TestCompleteProxyConfigurers_PreserveRawNames(t *testing.T) {
require := require.New(t)
enabled := true
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy-raw"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
proxyCfg.Enabled = &enabled
proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg})
require.Len(proxies, 1)
p := proxies[0].GetBaseConfig()
require.Equal("proxy-raw", p.Name)
require.Equal("127.0.0.1", p.LocalIP)
}
func TestCompleteVisitorConfigurers_PreserveRawNames(t *testing.T) {
require := require.New(t)
enabled := true
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor-raw"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server-raw"
visitorCfg.FallbackTo = "fallback-raw"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
visitorCfg.Enabled = &enabled
visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg})
require.Len(visitors, 1)
v := visitors[0].GetBaseConfig()
require.Equal("visitor-raw", v.Name)
require.Equal("server-raw", v.ServerName)
require.Equal("127.0.0.1", v.BindAddr)
xtcp := visitors[0].(*v1.XTCPVisitorConfig)
require.Equal("fallback-raw", xtcp.FallbackTo)
require.Equal("quic", xtcp.Protocol)
}
func TestCompleteProxyConfigurers_Idempotent(t *testing.T) {
require := require.New(t)
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg})
firstProxyJSON, err := json.Marshal(proxies[0])
require.NoError(err)
proxies = CompleteProxyConfigurers(proxies)
secondProxyJSON, err := json.Marshal(proxies[0])
require.NoError(err)
require.Equal(string(firstProxyJSON), string(secondProxyJSON))
}
func TestCompleteVisitorConfigurers_Idempotent(t *testing.T) {
require := require.New(t)
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg})
firstVisitorJSON, err := json.Marshal(visitors[0])
require.NoError(err)
visitors = CompleteVisitorConfigurers(visitors)
secondVisitorJSON, err := json.Marshal(visitors[0])
require.NoError(err)
require.Equal(string(firstVisitorJSON), string(secondVisitorJSON))
}
func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
require := require.New(t)
enabled := true
disabled := false
proxyKeep := &v1.TCPProxyConfig{}
proxyKeep.Name = "keep"
proxyKeep.Type = "tcp"
proxyKeep.LocalPort = 10080
proxyKeep.Enabled = &enabled
proxyDropByStart := &v1.TCPProxyConfig{}
proxyDropByStart.Name = "drop-by-start"
proxyDropByStart.Type = "tcp"
proxyDropByStart.LocalPort = 10081
proxyDropByStart.Enabled = &enabled
proxyDropByEnabled := &v1.TCPProxyConfig{}
proxyDropByEnabled.Name = "drop-by-enabled"
proxyDropByEnabled.Type = "tcp"
proxyDropByEnabled.LocalPort = 10082
proxyDropByEnabled.Enabled = &disabled
common := &v1.ClientCommonConfig{
Start: []string{"keep"},
}
proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{
proxyKeep,
proxyDropByStart,
proxyDropByEnabled,
}, nil)
require.Len(visitors, 0)
require.Len(proxies, 1)
require.Equal("keep", proxies[0].GetBaseConfig().Name)
}
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
func TestYAMLEdgeCases(t *testing.T) {
require := require.New(t)

View File

@@ -0,0 +1,125 @@
// 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 source
import (
"errors"
"fmt"
"sort"
"sync"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type sourceEntry struct {
source Source
}
type Aggregator struct {
mu sync.RWMutex
configSource *ConfigSource
storeSource *StoreSource
}
func NewAggregator(configSource *ConfigSource) *Aggregator {
if configSource == nil {
configSource = NewConfigSource()
}
return &Aggregator{
configSource: configSource,
}
}
func (a *Aggregator) SetStoreSource(storeSource *StoreSource) {
a.mu.Lock()
defer a.mu.Unlock()
a.storeSource = storeSource
}
func (a *Aggregator) ConfigSource() *ConfigSource {
return a.configSource
}
func (a *Aggregator) StoreSource() *StoreSource {
return a.storeSource
}
func (a *Aggregator) getSourcesLocked() []sourceEntry {
sources := make([]sourceEntry, 0, 2)
if a.configSource != nil {
sources = append(sources, sourceEntry{
source: a.configSource,
})
}
if a.storeSource != nil {
sources = append(sources, sourceEntry{
source: a.storeSource,
})
}
return sources
}
func (a *Aggregator) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
a.mu.RLock()
entries := a.getSourcesLocked()
a.mu.RUnlock()
if len(entries) == 0 {
return nil, nil, errors.New("no sources configured")
}
proxyMap := make(map[string]v1.ProxyConfigurer)
visitorMap := make(map[string]v1.VisitorConfigurer)
for _, entry := range entries {
proxies, visitors, err := entry.source.Load()
if err != nil {
return nil, nil, fmt.Errorf("load source: %w", err)
}
for _, p := range proxies {
proxyMap[p.GetBaseConfig().Name] = p
}
for _, v := range visitors {
visitorMap[v.GetBaseConfig().Name] = v
}
}
proxies, visitors := a.mapsToSortedSlices(proxyMap, visitorMap)
return proxies, visitors, nil
}
func (a *Aggregator) mapsToSortedSlices(
proxyMap map[string]v1.ProxyConfigurer,
visitorMap map[string]v1.VisitorConfigurer,
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
proxies := make([]v1.ProxyConfigurer, 0, len(proxyMap))
for _, p := range proxyMap {
proxies = append(proxies, p)
}
sort.Slice(proxies, func(i, j int) bool {
return proxies[i].GetBaseConfig().Name < proxies[j].GetBaseConfig().Name
})
visitors := make([]v1.VisitorConfigurer, 0, len(visitorMap))
for _, v := range visitorMap {
visitors = append(visitors, v)
}
sort.Slice(visitors, func(i, j int) bool {
return visitors[i].GetBaseConfig().Name < visitors[j].GetBaseConfig().Name
})
return proxies, visitors
}

View File

@@ -0,0 +1,217 @@
// 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 source
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// mockProxy creates a TCP proxy config for testing
func mockProxy(name string) v1.ProxyConfigurer {
cfg := &v1.TCPProxyConfig{}
cfg.Name = name
cfg.Type = "tcp"
cfg.LocalPort = 8080
cfg.RemotePort = 9090
return cfg
}
// mockVisitor creates a STCP visitor config for testing
func mockVisitor(name string) v1.VisitorConfigurer {
cfg := &v1.STCPVisitorConfig{}
cfg.Name = name
cfg.Type = "stcp"
cfg.ServerName = "test-server"
return cfg
}
func newTestStoreSource(t *testing.T) *StoreSource {
t.Helper()
path := filepath.Join(t.TempDir(), "store.json")
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(t, err)
return storeSource
}
func newTestAggregator(t *testing.T, storeSource *StoreSource) *Aggregator {
t.Helper()
configSource := NewConfigSource()
agg := NewAggregator(configSource)
if storeSource != nil {
agg.SetStoreSource(storeSource)
}
return agg
}
func TestNewAggregator_CreatesConfigSourceWhenNil(t *testing.T) {
require := require.New(t)
agg := NewAggregator(nil)
require.NotNil(agg)
require.NotNil(agg.ConfigSource())
require.Nil(agg.StoreSource())
}
func TestNewAggregator_WithoutStore(t *testing.T) {
require := require.New(t)
configSource := NewConfigSource()
agg := NewAggregator(configSource)
require.NotNil(agg)
require.Same(configSource, agg.ConfigSource())
require.Nil(agg.StoreSource())
}
func TestNewAggregator_WithStore(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
configSource := NewConfigSource()
agg := NewAggregator(configSource)
agg.SetStoreSource(storeSource)
require.Same(configSource, agg.ConfigSource())
require.Same(storeSource, agg.StoreSource())
}
func TestAggregator_SetStoreSource_Overwrite(t *testing.T) {
require := require.New(t)
agg := newTestAggregator(t, nil)
first := newTestStoreSource(t)
second := newTestStoreSource(t)
agg.SetStoreSource(first)
require.Same(first, agg.StoreSource())
agg.SetStoreSource(second)
require.Same(second, agg.StoreSource())
agg.SetStoreSource(nil)
require.Nil(agg.StoreSource())
}
func TestAggregator_MergeBySourceOrder(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
configSource := agg.ConfigSource()
configShared := mockProxy("shared").(*v1.TCPProxyConfig)
configShared.LocalPort = 1111
configOnly := mockProxy("only-in-config").(*v1.TCPProxyConfig)
configOnly.LocalPort = 1112
err := configSource.ReplaceAll([]v1.ProxyConfigurer{configShared, configOnly}, nil)
require.NoError(err)
storeShared := mockProxy("shared").(*v1.TCPProxyConfig)
storeShared.LocalPort = 2222
storeOnly := mockProxy("only-in-store").(*v1.TCPProxyConfig)
storeOnly.LocalPort = 2223
err = storeSource.AddProxy(storeShared)
require.NoError(err)
err = storeSource.AddProxy(storeOnly)
require.NoError(err)
proxies, visitors, err := agg.Load()
require.NoError(err)
require.Len(visitors, 0)
require.Len(proxies, 3)
var sharedProxy *v1.TCPProxyConfig
for _, p := range proxies {
if p.GetBaseConfig().Name == "shared" {
sharedProxy = p.(*v1.TCPProxyConfig)
break
}
}
require.NotNil(sharedProxy)
require.Equal(2222, sharedProxy.LocalPort)
}
func TestAggregator_DisabledEntryIsSourceLocalFilter(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
configSource := agg.ConfigSource()
lowProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
lowProxy.LocalPort = 1111
err := configSource.ReplaceAll([]v1.ProxyConfigurer{lowProxy}, nil)
require.NoError(err)
disabled := false
highProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
highProxy.LocalPort = 2222
highProxy.Enabled = &disabled
err = storeSource.AddProxy(highProxy)
require.NoError(err)
proxies, visitors, err := agg.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 0)
proxy := proxies[0].(*v1.TCPProxyConfig)
require.Equal("shared-proxy", proxy.Name)
require.Equal(1111, proxy.LocalPort)
}
func TestAggregator_VisitorMerge(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
err := agg.ConfigSource().ReplaceAll(nil, []v1.VisitorConfigurer{mockVisitor("visitor1")})
require.NoError(err)
err = storeSource.AddVisitor(mockVisitor("visitor2"))
require.NoError(err)
_, visitors, err := agg.Load()
require.NoError(err)
require.Len(visitors, 2)
}
func TestAggregator_Load_ReturnsSharedReferences(t *testing.T) {
require := require.New(t)
agg := newTestAggregator(t, nil)
err := agg.ConfigSource().ReplaceAll([]v1.ProxyConfigurer{mockProxy("ssh")}, nil)
require.NoError(err)
proxies, _, err := agg.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Equal("ssh", proxies[0].GetBaseConfig().Name)
proxies[0].GetBaseConfig().Name = "alice.ssh"
proxies2, _, err := agg.Load()
require.NoError(err)
require.Len(proxies2, 1)
require.Equal("alice.ssh", proxies2[0].GetBaseConfig().Name)
}

View File

@@ -0,0 +1,65 @@
// 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 source
import (
"sync"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// baseSource provides shared state and behavior for Source implementations.
// It manages proxy/visitor storage.
// Concrete types (ConfigSource, StoreSource) embed this struct.
type baseSource struct {
mu sync.RWMutex
proxies map[string]v1.ProxyConfigurer
visitors map[string]v1.VisitorConfigurer
}
func newBaseSource() baseSource {
return baseSource{
proxies: make(map[string]v1.ProxyConfigurer),
visitors: make(map[string]v1.VisitorConfigurer),
}
}
// Load returns all enabled proxy and visitor configurations.
// Configurations with Enabled explicitly set to false are filtered out.
func (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
proxies := make([]v1.ProxyConfigurer, 0, len(s.proxies))
for _, p := range s.proxies {
// Filter out disabled proxies (nil or true means enabled)
if enabled := p.GetBaseConfig().Enabled; enabled != nil && !*enabled {
continue
}
proxies = append(proxies, p)
}
visitors := make([]v1.VisitorConfigurer, 0, len(s.visitors))
for _, v := range s.visitors {
// Filter out disabled visitors (nil or true means enabled)
if enabled := v.GetBaseConfig().Enabled; enabled != nil && !*enabled {
continue
}
visitors = append(visitors, v)
}
return proxies, visitors, nil
}

View File

@@ -0,0 +1,65 @@
// 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 source
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// ConfigSource implements Source for in-memory configuration.
// All operations are thread-safe.
type ConfigSource struct {
baseSource
}
func NewConfigSource() *ConfigSource {
return &ConfigSource{
baseSource: newBaseSource(),
}
}
// ReplaceAll replaces all proxy and visitor configurations atomically.
func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
s.mu.Lock()
defer s.mu.Unlock()
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
for _, p := range proxies {
if p == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := p.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
nextProxies[name] = p
}
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
for _, v := range visitors {
if v == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := v.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
nextVisitors[name] = v
}
s.proxies = nextProxies
s.visitors = nextVisitors
return nil
}

View File

@@ -0,0 +1,173 @@
// 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 source
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestNewConfigSource(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
require.NotNil(src)
}
func TestConfigSource_ReplaceAll(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
err := src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
)
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2)
require.Len(visitors, 1)
// ReplaceAll again should replace everything
err = src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy3")},
nil,
)
require.NoError(err)
proxies, visitors, err = src.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 0)
require.Equal("proxy3", proxies[0].GetBaseConfig().Name)
// ReplaceAll with nil proxy should fail
err = src.ReplaceAll([]v1.ProxyConfigurer{nil}, nil)
require.Error(err)
// ReplaceAll with empty name proxy should fail
err = src.ReplaceAll([]v1.ProxyConfigurer{&v1.TCPProxyConfig{}}, nil)
require.Error(err)
}
func TestConfigSource_Load(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
err := src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
)
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2)
require.Len(visitors, 1)
}
// TestConfigSource_Load_FiltersDisabled verifies that Load() filters out
// proxies and visitors with Enabled explicitly set to false.
func TestConfigSource_Load_FiltersDisabled(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
disabled := false
enabled := true
// Create enabled proxy (nil Enabled = enabled by default)
enabledProxy := mockProxy("enabled-proxy")
// Create disabled proxy
disabledProxy := &v1.TCPProxyConfig{}
disabledProxy.Name = "disabled-proxy"
disabledProxy.Type = "tcp"
disabledProxy.Enabled = &disabled
// Create explicitly enabled proxy
explicitEnabledProxy := &v1.TCPProxyConfig{}
explicitEnabledProxy.Name = "explicit-enabled-proxy"
explicitEnabledProxy.Type = "tcp"
explicitEnabledProxy.Enabled = &enabled
// Create enabled visitor (nil Enabled = enabled by default)
enabledVisitor := mockVisitor("enabled-visitor")
// Create disabled visitor
disabledVisitor := &v1.STCPVisitorConfig{}
disabledVisitor.Name = "disabled-visitor"
disabledVisitor.Type = "stcp"
disabledVisitor.Enabled = &disabled
err := src.ReplaceAll(
[]v1.ProxyConfigurer{enabledProxy, disabledProxy, explicitEnabledProxy},
[]v1.VisitorConfigurer{enabledVisitor, disabledVisitor},
)
require.NoError(err)
// Load should filter out disabled configs
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2, "Should have 2 enabled proxies")
require.Len(visitors, 1, "Should have 1 enabled visitor")
// Verify the correct proxies are returned
proxyNames := make([]string, 0, len(proxies))
for _, p := range proxies {
proxyNames = append(proxyNames, p.GetBaseConfig().Name)
}
require.Contains(proxyNames, "enabled-proxy")
require.Contains(proxyNames, "explicit-enabled-proxy")
require.NotContains(proxyNames, "disabled-proxy")
// Verify the correct visitor is returned
require.Equal("enabled-visitor", visitors[0].GetBaseConfig().Name)
}
func TestConfigSource_ReplaceAll_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 1)
require.Empty(proxies[0].GetBaseConfig().LocalIP)
require.Empty(visitors[0].GetBaseConfig().BindAddr)
require.Empty(visitors[0].(*v1.XTCPVisitorConfig).Protocol)
}

View File

@@ -0,0 +1,37 @@
// 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 source
import (
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// Source is the interface for configuration sources.
// A Source provides proxy and visitor configurations from various backends.
// Aggregator currently uses the built-in config source as base and an optional
// store source as higher-priority overlay.
type Source interface {
// Load loads the proxy and visitor configurations from this source.
// Returns the loaded configurations and any error encountered.
// A disabled entry in one source is source-local filtering, not a cross-source
// tombstone for entries from lower-priority sources.
//
// Error handling contract with Aggregator:
// - When err is nil, returned slices are consumed.
// - When err is non-nil, Aggregator aborts the merge and returns the error.
// - To publish best-effort or partial results, return those results with
// err set to nil.
Load() (proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, err error)
}

351
pkg/config/source/store.go Normal file
View File

@@ -0,0 +1,351 @@
// 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 source
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type StoreSourceConfig struct {
Path string `json:"path"`
}
type storeData struct {
Proxies []v1.TypedProxyConfig `json:"proxies,omitempty"`
Visitors []v1.TypedVisitorConfig `json:"visitors,omitempty"`
}
type StoreSource struct {
baseSource
config StoreSourceConfig
}
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("path is required")
}
s := &StoreSource{
baseSource: newBaseSource(),
config: cfg,
}
if err := s.loadFromFile(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load existing data: %w", err)
}
}
return s, nil
}
func (s *StoreSource) loadFromFile() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.loadFromFileUnlocked()
}
func (s *StoreSource) loadFromFileUnlocked() error {
data, err := os.ReadFile(s.config.Path)
if err != nil {
return err
}
var stored storeData
if err := json.Unmarshal(data, &stored); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
}
s.proxies = make(map[string]v1.ProxyConfigurer)
s.visitors = make(map[string]v1.VisitorConfigurer)
for _, tp := range stored.Proxies {
if tp.ProxyConfigurer != nil {
proxyCfg := tp.ProxyConfigurer
name := proxyCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.proxies[name] = proxyCfg
}
}
for _, tv := range stored.Visitors {
if tv.VisitorConfigurer != nil {
visitorCfg := tv.VisitorConfigurer
name := visitorCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.visitors[name] = visitorCfg
}
}
return nil
}
func (s *StoreSource) saveToFileUnlocked() error {
stored := storeData{
Proxies: make([]v1.TypedProxyConfig, 0, len(s.proxies)),
Visitors: make([]v1.TypedVisitorConfig, 0, len(s.visitors)),
}
for _, p := range s.proxies {
stored.Proxies = append(stored.Proxies, v1.TypedProxyConfig{ProxyConfigurer: p})
}
for _, v := range s.visitors {
stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v})
}
data, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
dir := filepath.Dir(s.config.Path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
tmpPath := s.config.Path + ".tmp"
f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to close temp file: %w", err)
}
if err := os.Rename(tmpPath, s.config.Path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := proxy.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.proxies[name]; exists {
return fmt.Errorf("proxy %q already exists", name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
delete(s.proxies, name)
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := proxy.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("proxy %q does not exist", name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) RemoveProxy(name string) error {
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("proxy %q does not exist", name)
}
delete(s.proxies, name)
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
s.mu.RLock()
defer s.mu.RUnlock()
p, exists := s.proxies[name]
if !exists {
return nil
}
return p
}
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := visitor.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.visitors[name]; exists {
return fmt.Errorf("visitor %q already exists", name)
}
s.visitors[name] = visitor
if err := s.saveToFileUnlocked(); err != nil {
delete(s.visitors, name)
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := visitor.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("visitor %q does not exist", name)
}
s.visitors[name] = visitor
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) RemoveVisitor(name string) error {
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("visitor %q does not exist", name)
}
delete(s.visitors, name)
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
s.mu.RLock()
defer s.mu.RUnlock()
v, exists := s.visitors[name]
if !exists {
return nil
}
return v
}
func (s *StoreSource) GetAllProxies() ([]v1.ProxyConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]v1.ProxyConfigurer, 0, len(s.proxies))
for _, p := range s.proxies {
result = append(result, p)
}
return result, nil
}
func (s *StoreSource) GetAllVisitors() ([]v1.VisitorConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]v1.VisitorConfigurer, 0, len(s.visitors))
for _, v := range s.visitors {
result = append(result, v)
}
return result, nil
}

View File

@@ -0,0 +1,99 @@
// 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 source
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "store.json")
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(err)
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
err = storeSource.AddProxy(proxyCfg)
require.NoError(err)
err = storeSource.AddVisitor(visitorCfg)
require.NoError(err)
gotProxy := storeSource.GetProxy("proxy1")
require.NotNil(gotProxy)
require.Empty(gotProxy.GetBaseConfig().LocalIP)
gotVisitor := storeSource.GetVisitor("visitor1")
require.NotNil(gotVisitor)
require.Empty(gotVisitor.GetBaseConfig().BindAddr)
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
}
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "store.json")
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
stored := storeData{
Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}},
Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}},
}
data, err := json.Marshal(stored)
require.NoError(err)
err = os.WriteFile(path, data, 0o600)
require.NoError(err)
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(err)
gotProxy := storeSource.GetProxy("proxy1")
require.NotNil(gotProxy)
require.Empty(gotProxy.GetBaseConfig().LocalIP)
gotVisitor := storeSource.GetVisitor("visitor1")
require.NotNil(gotVisitor)
require.Empty(gotVisitor.GetBaseConfig().BindAddr)
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
}

View File

@@ -77,6 +77,9 @@ type ClientCommonConfig struct {
// Include other config files for proxies.
IncludeConfigFiles []string `json:"includes,omitempty"`
// Store config enables the built-in store source (not configurable via sources list).
Store StoreConfig `json:"store,omitempty"`
}
func (c *ClientCommonConfig) Complete() error {

View File

@@ -21,8 +21,6 @@ import (
"fmt"
"reflect"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/types"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/util"
@@ -126,8 +124,7 @@ func (c *ProxyBaseConfig) GetBaseConfig() *ProxyBaseConfig {
return c
}
func (c *ProxyBaseConfig) Complete(namePrefix string) {
c.Name = lo.Ternary(namePrefix == "", "", namePrefix+".") + c.Name
func (c *ProxyBaseConfig) Complete() {
c.LocalIP = util.EmptyOr(c.LocalIP, "127.0.0.1")
c.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient)
@@ -207,7 +204,7 @@ func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {
}
type ProxyConfigurer interface {
Complete(namePrefix string)
Complete()
GetBaseConfig() *ProxyBaseConfig
// MarshalToMsg marshals this config into a msg.NewProxy message. This
// function will be called on the frpc side.

26
pkg/config/v1/store.go Normal file
View File

@@ -0,0 +1,26 @@
// 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 v1
// StoreConfig configures the built-in store source.
type StoreConfig struct {
// Path is the store file path.
Path string `json:"path,omitempty"`
}
// IsEnabled returns true if the store is configured with a valid path.
func (c *StoreConfig) IsEnabled() bool {
return c.Path != ""
}

View File

@@ -81,7 +81,7 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
for _, domain := range c.CustomDomains {
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
if strings.Contains(domain, s.SubDomainHost) {
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
}
}

View File

@@ -21,8 +21,6 @@ import (
"fmt"
"reflect"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -56,26 +54,14 @@ func (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig {
return c
}
func (c *VisitorBaseConfig) Complete(g *ClientCommonConfig) {
func (c *VisitorBaseConfig) Complete() {
if c.BindAddr == "" {
c.BindAddr = "127.0.0.1"
}
namePrefix := ""
if g.User != "" {
namePrefix = g.User + "."
}
c.Name = namePrefix + c.Name
if c.ServerUser != "" {
c.ServerName = c.ServerUser + "." + c.ServerName
} else {
c.ServerName = namePrefix + c.ServerName
}
}
type VisitorConfigurer interface {
Complete(*ClientCommonConfig)
Complete()
GetBaseConfig() *VisitorBaseConfig
}
@@ -168,15 +154,11 @@ type XTCPVisitorConfig struct {
NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
}
func (c *XTCPVisitorConfig) Complete(g *ClientCommonConfig) {
c.VisitorBaseConfig.Complete(g)
func (c *XTCPVisitorConfig) Complete() {
c.VisitorBaseConfig.Complete()
c.Protocol = util.EmptyOr(c.Protocol, "quic")
c.MaxRetriesAnHour = util.EmptyOr(c.MaxRetriesAnHour, 8)
c.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90)
c.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000)
if c.FallbackTo != "" {
c.FallbackTo = lo.Ternary(g.User == "", "", g.User+".") + c.FallbackTo
}
}

View File

@@ -112,7 +112,7 @@ func (s *TunnelServer) Run() error {
if sshConn.Permissions != nil {
clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User)
}
pc.Complete(clientCfg.User)
pc.Complete()
vc, err := virtual.NewClient(virtual.ClientOptions{
Common: clientCfg,

33
pkg/util/util/names.go Normal file
View File

@@ -0,0 +1,33 @@
package util
import "strings"
// AddUserPrefix builds the wire-level proxy name for frps by prefixing user.
func AddUserPrefix(user, name string) string {
if user == "" {
return name
}
return user + "." + name
}
// StripUserPrefix converts a wire-level proxy name to an internal raw name.
// It strips only one exact "{user}." prefix.
func StripUserPrefix(user, name string) string {
if user == "" {
return name
}
prefix := user + "."
if strings.HasPrefix(name, prefix) {
return strings.TrimPrefix(name, prefix)
}
return name
}
// BuildTargetServerProxyName resolves visitor target proxy name for wire-level
// protocol messages. serverUser overrides local user when set.
func BuildTargetServerProxyName(localUser, serverUser, serverName string) string {
if serverUser != "" {
return AddUserPrefix(serverUser, serverName)
}
return AddUserPrefix(localUser, serverName)
}

View File

@@ -41,3 +41,23 @@ func TestParseRangeNumbers(t *testing.T) {
_, err = ParseRangeNumbers("3-a")
require.Error(err)
}
func TestAddUserPrefix(t *testing.T) {
require := require.New(t)
require.Equal("test", AddUserPrefix("", "test"))
require.Equal("alice.test", AddUserPrefix("alice", "test"))
}
func TestStripUserPrefix(t *testing.T) {
require := require.New(t)
require.Equal("test", StripUserPrefix("", "test"))
require.Equal("test", StripUserPrefix("alice", "alice.test"))
require.Equal("alice.test", StripUserPrefix("alice", "alice.alice.test"))
require.Equal("bob.test", StripUserPrefix("alice", "bob.test"))
}
func TestBuildTargetServerProxyName(t *testing.T) {
require := require.New(t)
require.Equal("alice.test", BuildTargetServerProxyName("alice", "", "test"))
require.Equal("bob.test", BuildTargetServerProxyName("alice", "bob", "test"))
}

View File

@@ -19,6 +19,7 @@ import (
"net"
"github.com/fatedier/frp/client"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -43,10 +44,13 @@ func NewClient(options ClientOptions) (*Client, error) {
}
ln := netpkg.NewInternalListener()
configSource := source.NewConfigSource()
aggregator := source.NewAggregator(configSource)
serviceOptions := client.ServiceOptions{
Common: options.Common,
ClientSpec: options.Spec,
Common: options.Common,
ConfigSourceAggregator: aggregator,
ClientSpec: options.Spec,
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) client.Connector {
return &pipeConnector{
peerListener: ln,

View File

@@ -236,9 +236,6 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Status = "online"
c.fillProxyClientInfo(&proxyClientInfo{
clientVersion: &proxyInfo.ClientVersion,
}, pxy)
} else {
proxyInfo.Status = "offline"
}
@@ -277,9 +274,6 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
continue
}
proxyInfo.Status = "online"
c.fillProxyClientInfo(&proxyClientInfo{
clientVersion: &proxyInfo.ClientVersion,
}, pxy)
} else {
proxyInfo.Status = "offline"
}
@@ -339,6 +333,7 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
User: info.User,
ClientID: info.ClientID(),
RunID: info.RunID,
Version: info.Version,
Hostname: info.Hostname,
ClientIP: info.IP,
FirstConnectedAt: toUnix(info.FirstConnectedAt),
@@ -351,37 +346,6 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
return resp
}
type proxyClientInfo struct {
user *string
clientID *string
clientVersion *string
}
func (c *Controller) fillProxyClientInfo(proxyInfo *proxyClientInfo, pxy proxy.Proxy) {
loginMsg := pxy.GetLoginMsg()
if loginMsg == nil {
return
}
if proxyInfo.user != nil {
*proxyInfo.user = loginMsg.User
}
if proxyInfo.clientVersion != nil {
*proxyInfo.clientVersion = loginMsg.Version
}
if info, ok := c.clientRegistry.GetByRunID(loginMsg.RunID); ok {
if proxyInfo.clientID != nil {
*proxyInfo.clientID = info.ClientID()
}
return
}
if proxyInfo.clientID != nil {
*proxyInfo.clientID = loginMsg.ClientID
if *proxyInfo.clientID == "" {
*proxyInfo.clientID = loginMsg.RunID
}
}
}
func toUnix(t time.Time) int64 {
if t.IsZero() {
return 0

View File

@@ -45,6 +45,7 @@ type ClientInfoResp struct {
User string `json:"user"`
ClientID string `json:"clientID"`
RunID string `json:"runID"`
Version string `json:"version,omitempty"`
Hostname string `json:"hostname"`
ClientIP string `json:"clientIP,omitempty"`
FirstConnectedAt int64 `json:"firstConnectedAt"`
@@ -100,7 +101,6 @@ type ProxyStatsInfo struct {
Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
@@ -119,7 +119,6 @@ type GetProxyStatsResp struct {
Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`

View File

@@ -28,6 +28,7 @@ type ClientInfo struct {
RunID string
Hostname string
IP string
Version string
FirstConnectedAt time.Time
LastConnectedAt time.Time
DisconnectedAt time.Time
@@ -50,7 +51,7 @@ func NewClientRegistry() *ClientRegistry {
}
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr string) (key string, conflict bool) {
if runID == "" {
return "", false
}
@@ -86,6 +87,7 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAdd
info.RunID = runID
info.Hostname = hostname
info.IP = remoteAddr
info.Version = version
if info.FirstConnectedAt.IsZero() {
info.FirstConnectedAt = now
}
@@ -151,22 +153,6 @@ func (info ClientInfo) ClientID() string {
return info.RunID
}
// GetByRunID retrieves a client by its run ID.
func (cr *ClientRegistry) GetByRunID(runID string) (ClientInfo, bool) {
cr.mu.RLock()
defer cr.mu.RUnlock()
key, ok := cr.runIndex[runID]
if !ok {
return ClientInfo{}, false
}
info, ok := cr.clients[key]
if !ok {
return ClientInfo{}, false
}
return *info, true
}
func (cr *ClientRegistry) composeClientKey(user, id string) string {
switch {
case user == "":

View File

@@ -622,7 +622,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
remoteAddr = host
}
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, remoteAddr)
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr)
if conflict {
svr.ctlManager.Del(loginMsg.RunID, ctl)
ctl.Close()

View File

@@ -0,0 +1,230 @@
package features
import (
"encoding/json"
"fmt"
"strings"
"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/pkg/request"
)
var _ = ginkgo.Describe("[Feature: Store]", func() {
f := framework.NewDefaultFramework()
ginkgo.Describe("Store API", func() {
ginkgo.It("create proxy via API and verify connection", func() {
adminPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
webServer.addr = "127.0.0.1"
webServer.port = %d
[store]
path = "%s/store.json"
`, adminPort, f.TempDirectory)
f.RunProcesses([]string{serverConf}, []string{clientConf})
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
}
proxyBody, _ := json.Marshal(proxyConfig)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
"Content-Type": "application/json",
}).Body(proxyBody)
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 200
})
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
ginkgo.It("update proxy via API", func() {
adminPort := f.AllocPort()
remotePort1 := f.AllocPort()
remotePort2 := f.AllocPort()
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
webServer.addr = "127.0.0.1"
webServer.port = %d
[store]
path = "%s/store.json"
`, adminPort, f.TempDirectory)
f.RunProcesses([]string{serverConf}, []string{clientConf})
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort1,
}
proxyBody, _ := json.Marshal(proxyConfig)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
"Content-Type": "application/json",
}).Body(proxyBody)
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 200
})
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort1).Ensure()
proxyConfig["remotePort"] = remotePort2
proxyBody, _ = json.Marshal(proxyConfig)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("PUT", "", "/api/store/proxies/test-tcp", map[string]string{
"Content-Type": "application/json",
}).Body(proxyBody)
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 200
})
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort2).Ensure()
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true)
})
ginkgo.It("delete proxy via API", func() {
adminPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
webServer.addr = "127.0.0.1"
webServer.port = %d
[store]
path = "%s/store.json"
`, adminPort, f.TempDirectory)
f.RunProcesses([]string{serverConf}, []string{clientConf})
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
}
proxyBody, _ := json.Marshal(proxyConfig)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
"Content-Type": "application/json",
}).Body(proxyBody)
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 200
})
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("DELETE", "", "/api/store/proxies/test-tcp", nil)
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 200
})
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true)
})
ginkgo.It("list and get proxy via API", func() {
adminPort := f.AllocPort()
remotePort := f.AllocPort()
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
webServer.addr = "127.0.0.1"
webServer.port = %d
[store]
path = "%s/store.json"
`, adminPort, f.TempDirectory)
f.RunProcesses([]string{serverConf}, []string{clientConf})
time.Sleep(500 * time.Millisecond)
proxyConfig := map[string]any{
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
}
proxyBody, _ := json.Marshal(proxyConfig)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies").HTTPParams("POST", "", "/api/store/proxies", map[string]string{
"Content-Type": "application/json",
}).Body(proxyBody)
}).Ensure(func(resp *request.Response) bool {
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 {
return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp")
})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp")
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp")
})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/nonexistent")
}).Ensure(func(resp *request.Response) bool {
return resp.Code == 404
})
})
ginkgo.It("store disabled returns 404", func() {
adminPort := f.AllocPort()
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
webServer.addr = "127.0.0.1"
webServer.port = %d
`, adminPort)
f.RunProcesses([]string{serverConf}, []string{clientConf})
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 {
return resp.Code == 404
})
})
})
})

14
todo.md Normal file
View File

@@ -0,0 +1,14 @@
# TODO
## Frontend
- [ ] Disabled proxy 在前端不显示的问题
- 当前行为:`enabled: false` 的代理在 `pkg/config/load.go` 中被过滤,不会加载到 proxy manager前端无法看到
- 需要考虑:是否应该在前端显示 disabled 的代理(以灰色或其他方式标识),并允许用户启用/禁用
- [ ] Store proxy 删除后前端列表没有及时刷新
- 原因:`RemoveProxy` 通过 `notifyChangeUnlocked()` 异步通知变更,前端立即调用 `fetchData()` 时 proxy manager 可能还没处理完
- 可能的解决方案:
1. 后端删除 API 等待 proxy manager 更新完成后再返回
2. 前端乐观更新,先从列表移除再后台刷新
3. 前端适当延迟后再刷新(不优雅)

View File

@@ -10,13 +10,23 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
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']
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']
KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@@ -65,6 +65,10 @@ const isDark = useDark()
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 ''
})
</script>

View File

@@ -1,5 +1,11 @@
import { http } from './http'
import type { StatusResponse } from '../types/proxy'
import type {
StatusResponse,
StoreProxyListResp,
StoreProxyConfig,
StoreVisitorListResp,
StoreVisitorConfig,
} from '../types/proxy'
export const getStatus = () => {
return http.get<StatusResponse>('/api/status')
@@ -16,3 +22,58 @@ export const putConfig = (content: string) => {
export const reloadConfig = () => {
return http.get<void>('/api/reload')
}
// Store API - Proxies
export const listStoreProxies = () => {
return http.get<StoreProxyListResp>('/api/store/proxies')
}
export const getStoreProxy = (name: string) => {
return http.get<StoreProxyConfig>(
`/api/store/proxies/${encodeURIComponent(name)}`,
)
}
export const createStoreProxy = (config: Record<string, any>) => {
return http.post<void>('/api/store/proxies', config)
}
export const updateStoreProxy = (name: string, config: Record<string, any>) => {
return http.put<void>(
`/api/store/proxies/${encodeURIComponent(name)}`,
config,
)
}
export const deleteStoreProxy = (name: string) => {
return http.delete<void>(`/api/store/proxies/${encodeURIComponent(name)}`)
}
// Store API - Visitors
export const listStoreVisitors = () => {
return http.get<StoreVisitorListResp>('/api/store/visitors')
}
export const getStoreVisitor = (name: string) => {
return http.get<StoreVisitorConfig>(
`/api/store/visitors/${encodeURIComponent(name)}`,
)
}
export const createStoreVisitor = (config: Record<string, any>) => {
return http.post<void>('/api/store/visitors', config)
}
export const updateStoreVisitor = (
name: string,
config: Record<string, any>,
) => {
return http.put<void>(
`/api/store/visitors/${encodeURIComponent(name)}`,
config,
)
}
export const deleteStoreVisitor = (name: string) => {
return http.delete<void>(`/api/store/visitors/${encodeURIComponent(name)}`)
}

View File

@@ -0,0 +1,150 @@
<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)">
<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="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>
</template>
<script setup lang="ts">
interface KVEntry {
key: string
value: string
}
interface Props {
modelValue: KVEntry[]
keyPlaceholder?: string
valuePlaceholder?: string
}
const props = withDefaults(defineProps<Props>(), {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value',
})
const emit = defineEmits<{
'update:modelValue': [value: KVEntry[]]
}>()
const updateEntry = (index: number, field: 'key' | 'value', val: string) => {
const updated = [...props.modelValue]
updated[index] = { ...updated[index], [field]: val }
emit('update:modelValue', updated)
}
const addEntry = () => {
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
}
const removeEntry = (index: number) => {
const updated = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', updated)
}
</script>
<style scoped>
.kv-editor {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.kv-row {
display: flex;
gap: 8px;
align-items: center;
}
.kv-input {
flex: 1;
}
.kv-remove-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;
flex-shrink: 0;
}
.kv-remove-btn svg {
width: 14px;
height: 14px;
}
.kv-remove-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
html.dark .kv-remove-btn:hover {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}
.kv-add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
background: transparent;
color: var(--el-text-color-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
align-self: flex-start;
}
.kv-add-btn svg {
width: 14px;
height: 14px;
}
.kv-add-btn:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
</style>

View File

@@ -1,23 +1,46 @@
<template>
<div class="proxy-card" :class="{ 'has-error': proxy.err }">
<div
class="proxy-card"
:class="{ 'has-error': proxy.err, 'is-store': isStore }"
>
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</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>
</div>
<div class="card-meta">
<span v-if="proxy.local_addr" class="meta-item">
<span class="meta-label">Local:</span>
<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-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-label">Remote</span>
<span class="meta-value code">{{ proxy.remote_addr }}</span>
</span>
</div>
@@ -25,12 +48,58 @@
<div class="card-right">
<div v-if="proxy.err" class="error-info">
<el-icon class="error-icon"><Warning /></el-icon>
<span class="error-text">{{ proxy.err }}</span>
<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>
</div>
</div>
</div>
</div>
@@ -47,6 +116,13 @@ interface Props {
const props = defineProps<Props>()
defineEmits<{
edit: [proxy: ProxyStatus]
delete: [proxy: ProxyStatus]
}>()
const isStore = computed(() => props.proxy.source === 'store')
const statusClass = computed(() => {
switch (props.proxy.status) {
case 'running':
@@ -61,17 +137,20 @@ const statusClass = computed(() => {
<style scoped>
.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.2s ease-in-out;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.proxy-card:hover {
border-color: var(--el-border-color-light);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
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);
}
.proxy-card.has-error {
@@ -86,9 +165,9 @@ html.dark .proxy-card.has-error {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
gap: 24px;
min-height: 80px;
padding: 18px 20px;
gap: 20px;
min-height: 76px;
}
/* Left Section */
@@ -96,7 +175,7 @@ html.dark .proxy-card.has-error {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
gap: 10px;
flex: 1;
min-width: 0;
}
@@ -104,120 +183,297 @@ html.dark .proxy-card.has-error {
.card-header {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-wrap: wrap;
}
.proxy-name {
font-size: 16px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
line-height: 1.3;
letter-spacing: -0.01em;
}
.type-tag {
font-size: 11px;
font-weight: 500;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.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 {
display: flex;
align-items: center;
gap: 20px;
gap: 16px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: baseline;
align-items: center;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 13px;
font-size: 12px;
font-weight: 500;
}
.meta-value {
font-size: 13px;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.meta-value.code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
padding: 3px 7px;
border-radius: 5px;
font-size: 11px;
letter-spacing: -0.02em;
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: 16px;
gap: 12px;
flex-shrink: 0;
}
.error-info {
.error-badge {
display: flex;
align-items: center;
gap: 6px;
max-width: 200px;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
background: var(--el-color-danger-light-9);
cursor: help;
}
.error-icon {
color: var(--el-color-danger);
font-size: 16px;
flex-shrink: 0;
font-size: 14px;
}
.error-text {
font-size: 12px;
font-size: 11px;
font-weight: 500;
color: var(--el-color-danger);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
display: inline-flex;
padding: 4px 12px;
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;
}
.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: none;
gap: 4px;
}
.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) {
.card-main {
flex-direction: column;
align-items: stretch;
gap: 16px;
padding: 16px;
gap: 14px;
padding: 14px 16px;
}
.card-right {
@@ -225,12 +481,12 @@ html.dark .proxy-card.has-error {
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
padding-top: 14px;
}
.error-info {
max-width: none;
flex: 1;
.card-actions {
opacity: 1;
transform: none;
}
}
</style>

View File

@@ -1,6 +1,8 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Overview from '../views/Overview.vue'
import ClientConfigure from '../views/ClientConfigure.vue'
import ProxyEdit from '../views/ProxyEdit.vue'
import VisitorEdit from '../views/VisitorEdit.vue'
const router = createRouter({
history: createWebHashHistory(),
@@ -15,6 +17,26 @@ const router = createRouter({
name: 'ClientConfigure',
component: ClientConfigure,
},
{
path: '/proxies/create',
name: 'ProxyCreate',
component: ProxyEdit,
},
{
path: '/proxies/:name/edit',
name: 'ProxyEdit',
component: ProxyEdit,
},
{
path: '/visitors/create',
name: 'VisitorCreate',
component: VisitorEdit,
},
{
path: '/visitors/:name/edit',
name: 'VisitorEdit',
component: VisitorEdit,
},
],
})

View File

@@ -1,3 +1,7 @@
// ========================================
// RUNTIME STATUS TYPES (from /api/status)
// ========================================
export interface ProxyStatus {
name: string
type: string
@@ -6,7 +10,635 @@ export interface ProxyStatus {
local_addr: string
plugin: string
remote_addr: string
source?: 'store' | 'config'
[key: string]: any
}
export type StatusResponse = Record<string, ProxyStatus[]>
// ========================================
// STORE API TYPES
// ========================================
export interface StoreProxyConfig {
name: string
type: string
config: Record<string, any>
}
export interface StoreVisitorConfig {
name: string
type: string
config: Record<string, any>
}
export interface StoreProxyListResp {
proxies: StoreProxyConfig[]
}
export interface StoreVisitorListResp {
visitors: StoreVisitorConfig[]
}
// ========================================
// CONSTANTS
// ========================================
export const PROXY_TYPES = [
'tcp',
'udp',
'http',
'https',
'stcp',
'sudp',
'xtcp',
'tcpmux',
] as const
export type ProxyType = (typeof PROXY_TYPES)[number]
export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const
export type VisitorType = (typeof VISITOR_TYPES)[number]
export const PLUGIN_TYPES = [
'',
'http2https',
'http_proxy',
'https2http',
'https2https',
'http2http',
'socks5',
'static_file',
'unix_domain_socket',
'tls2raw',
'virtual_net',
] as const
export type PluginType = (typeof PLUGIN_TYPES)[number]
// ========================================
// FORM DATA INTERFACES
// ========================================
export interface ProxyFormData {
// Base fields (ProxyBaseConfig)
name: string
type: ProxyType
enabled: boolean
// Backend (ProxyBackend)
localIP: string
localPort: number | undefined
pluginType: string
pluginConfig: Record<string, any>
// Transport (ProxyTransport)
useEncryption: boolean
useCompression: boolean
bandwidthLimit: string
bandwidthLimitMode: string
proxyProtocolVersion: string
// Load Balancer (LoadBalancerConfig)
loadBalancerGroup: string
loadBalancerGroupKey: string
// Health Check (HealthCheckConfig)
healthCheckType: string
healthCheckTimeoutSeconds: number | undefined
healthCheckMaxFailed: number | undefined
healthCheckIntervalSeconds: number | undefined
healthCheckPath: string
healthCheckHTTPHeaders: Array<{ name: string; value: string }>
// Metadata & Annotations
metadatas: Array<{ key: string; value: string }>
annotations: Array<{ key: string; value: string }>
// TCP/UDP specific
remotePort: number | undefined
// Domain (HTTP/HTTPS/TCPMux) - DomainConfig
customDomains: string
subdomain: string
// HTTP specific (HTTPProxyConfig)
locations: string
httpUser: string
httpPassword: string
hostHeaderRewrite: string
requestHeaders: Array<{ key: string; value: string }>
responseHeaders: Array<{ key: string; value: string }>
routeByHTTPUser: string
// TCPMux specific
multiplexer: string
// STCP/SUDP/XTCP specific
secretKey: string
allowUsers: string
// XTCP specific (NatTraversalConfig)
natTraversalDisableAssistedAddrs: boolean
}
export interface VisitorFormData {
// Base fields (VisitorBaseConfig)
name: string
type: VisitorType
enabled: boolean
// Transport (VisitorTransport)
useEncryption: boolean
useCompression: boolean
// Connection
secretKey: string
serverUser: string
serverName: string
bindAddr: string
bindPort: number | undefined
// XTCP specific (XTCPVisitorConfig)
protocol: string
keepTunnelOpen: boolean
maxRetriesAnHour: number | undefined
minRetryInterval: number | undefined
fallbackTo: string
fallbackTimeoutMs: number | undefined
natTraversalDisableAssistedAddrs: boolean
}
// ========================================
// DEFAULT FORM CREATORS
// ========================================
export function createDefaultProxyForm(): ProxyFormData {
return {
name: '',
type: 'tcp',
enabled: true,
localIP: '127.0.0.1',
localPort: undefined,
pluginType: '',
pluginConfig: {},
useEncryption: false,
useCompression: false,
bandwidthLimit: '',
bandwidthLimitMode: 'client',
proxyProtocolVersion: '',
loadBalancerGroup: '',
loadBalancerGroupKey: '',
healthCheckType: '',
healthCheckTimeoutSeconds: undefined,
healthCheckMaxFailed: undefined,
healthCheckIntervalSeconds: undefined,
healthCheckPath: '',
healthCheckHTTPHeaders: [],
metadatas: [],
annotations: [],
remotePort: undefined,
customDomains: '',
subdomain: '',
locations: '',
httpUser: '',
httpPassword: '',
hostHeaderRewrite: '',
requestHeaders: [],
responseHeaders: [],
routeByHTTPUser: '',
multiplexer: 'httpconnect',
secretKey: '',
allowUsers: '',
natTraversalDisableAssistedAddrs: false,
}
}
export function createDefaultVisitorForm(): VisitorFormData {
return {
name: '',
type: 'stcp',
enabled: true,
useEncryption: false,
useCompression: false,
secretKey: '',
serverUser: '',
serverName: '',
bindAddr: '127.0.0.1',
bindPort: undefined,
protocol: 'quic',
keepTunnelOpen: false,
maxRetriesAnHour: undefined,
minRetryInterval: undefined,
fallbackTo: '',
fallbackTimeoutMs: undefined,
natTraversalDisableAssistedAddrs: false,
}
}
// ========================================
// CONVERTERS: Form -> Store API
// ========================================
export function formToStoreProxy(form: ProxyFormData): Record<string, any> {
const config: Record<string, any> = {
name: form.name,
type: form.type,
}
// Enabled (nil/true = enabled, false = disabled)
if (!form.enabled) {
config.enabled = false
}
// Backend - LocalIP/LocalPort
if (form.pluginType === '') {
// No plugin, use local backend
if (form.localIP && form.localIP !== '127.0.0.1') {
config.localIP = form.localIP
}
if (form.localPort != null) {
config.localPort = form.localPort
}
} else {
// Plugin backend
config.plugin = {
type: form.pluginType,
...form.pluginConfig,
}
}
// Transport
if (
form.useEncryption ||
form.useCompression ||
form.bandwidthLimit ||
(form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') ||
form.proxyProtocolVersion
) {
config.transport = {}
if (form.useEncryption) config.transport.useEncryption = true
if (form.useCompression) config.transport.useCompression = true
if (form.bandwidthLimit)
config.transport.bandwidthLimit = form.bandwidthLimit
if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') {
config.transport.bandwidthLimitMode = form.bandwidthLimitMode
}
if (form.proxyProtocolVersion) {
config.transport.proxyProtocolVersion = form.proxyProtocolVersion
}
}
// Load Balancer
if (form.loadBalancerGroup) {
config.loadBalancer = {
group: form.loadBalancerGroup,
}
if (form.loadBalancerGroupKey) {
config.loadBalancer.groupKey = form.loadBalancerGroupKey
}
}
// Health Check
if (form.healthCheckType) {
config.healthCheck = {
type: form.healthCheckType,
}
if (form.healthCheckTimeoutSeconds != null) {
config.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds
}
if (form.healthCheckMaxFailed != null) {
config.healthCheck.maxFailed = form.healthCheckMaxFailed
}
if (form.healthCheckIntervalSeconds != null) {
config.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds
}
if (form.healthCheckPath) {
config.healthCheck.path = form.healthCheckPath
}
if (form.healthCheckHTTPHeaders.length > 0) {
config.healthCheck.httpHeaders = form.healthCheckHTTPHeaders
}
}
// Metadata
if (form.metadatas.length > 0) {
config.metadatas = Object.fromEntries(
form.metadatas.map((m) => [m.key, m.value]),
)
}
// Annotations
if (form.annotations.length > 0) {
config.annotations = Object.fromEntries(
form.annotations.map((a) => [a.key, a.value]),
)
}
// Type-specific fields
if (form.type === 'tcp' || form.type === 'udp') {
if (form.remotePort != null) {
config.remotePort = form.remotePort
}
}
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
// Domain config
if (form.customDomains) {
config.customDomains = form.customDomains
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (form.subdomain) {
config.subdomain = form.subdomain
}
}
if (form.type === 'http') {
// HTTP specific
if (form.locations) {
config.locations = form.locations
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (form.httpUser) config.httpUser = form.httpUser
if (form.httpPassword) config.httpPassword = form.httpPassword
if (form.hostHeaderRewrite)
config.hostHeaderRewrite = form.hostHeaderRewrite
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
// Header operations
if (form.requestHeaders.length > 0) {
config.requestHeaders = {
set: Object.fromEntries(
form.requestHeaders.map((h) => [h.key, h.value]),
),
}
}
if (form.responseHeaders.length > 0) {
config.responseHeaders = {
set: Object.fromEntries(
form.responseHeaders.map((h) => [h.key, h.value]),
),
}
}
}
if (form.type === 'tcpmux') {
// TCPMux specific
if (form.httpUser) config.httpUser = form.httpUser
if (form.httpPassword) config.httpPassword = form.httpPassword
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
if (form.multiplexer && form.multiplexer !== 'httpconnect') {
config.multiplexer = form.multiplexer
}
}
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
// Secure proxy types
if (form.secretKey) config.secretKey = form.secretKey
if (form.allowUsers) {
config.allowUsers = form.allowUsers
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
}
if (form.type === 'xtcp') {
// XTCP NAT traversal
if (form.natTraversalDisableAssistedAddrs) {
config.natTraversal = {
disableAssistedAddrs: true,
}
}
}
return config
}
export function formToStoreVisitor(form: VisitorFormData): Record<string, any> {
const config: Record<string, any> = {
name: form.name,
type: form.type,
}
// Enabled
if (!form.enabled) {
config.enabled = false
}
// Transport
if (form.useEncryption || form.useCompression) {
config.transport = {}
if (form.useEncryption) config.transport.useEncryption = true
if (form.useCompression) config.transport.useCompression = true
}
// Base fields
if (form.secretKey) config.secretKey = form.secretKey
if (form.serverUser) config.serverUser = form.serverUser
if (form.serverName) config.serverName = form.serverName
if (form.bindAddr && form.bindAddr !== '127.0.0.1') {
config.bindAddr = form.bindAddr
}
if (form.bindPort != null) {
config.bindPort = form.bindPort
}
// XTCP specific
if (form.type === 'xtcp') {
if (form.protocol && form.protocol !== 'quic') {
config.protocol = form.protocol
}
if (form.keepTunnelOpen) {
config.keepTunnelOpen = true
}
if (form.maxRetriesAnHour != null) {
config.maxRetriesAnHour = form.maxRetriesAnHour
}
if (form.minRetryInterval != null) {
config.minRetryInterval = form.minRetryInterval
}
if (form.fallbackTo) {
config.fallbackTo = form.fallbackTo
}
if (form.fallbackTimeoutMs != null) {
config.fallbackTimeoutMs = form.fallbackTimeoutMs
}
if (form.natTraversalDisableAssistedAddrs) {
config.natTraversal = {
disableAssistedAddrs: true,
}
}
}
return config
}
// ========================================
// CONVERTERS: Store API -> Form
// ========================================
export function storeProxyToForm(config: StoreProxyConfig): ProxyFormData {
const c = config.config || {}
const form = createDefaultProxyForm()
form.name = config.name || ''
form.type = (config.type as ProxyType) || 'tcp'
form.enabled = c.enabled !== false
// Backend
form.localIP = c.localIP || '127.0.0.1'
form.localPort = c.localPort
if (c.plugin?.type) {
form.pluginType = c.plugin.type
form.pluginConfig = { ...c.plugin }
delete form.pluginConfig.type
}
// Transport
if (c.transport) {
form.useEncryption = c.transport.useEncryption || false
form.useCompression = c.transport.useCompression || false
form.bandwidthLimit = c.transport.bandwidthLimit || ''
form.bandwidthLimitMode = c.transport.bandwidthLimitMode || 'client'
form.proxyProtocolVersion = c.transport.proxyProtocolVersion || ''
}
// Load Balancer
if (c.loadBalancer) {
form.loadBalancerGroup = c.loadBalancer.group || ''
form.loadBalancerGroupKey = c.loadBalancer.groupKey || ''
}
// Health Check
if (c.healthCheck) {
form.healthCheckType = c.healthCheck.type || ''
form.healthCheckTimeoutSeconds = c.healthCheck.timeoutSeconds
form.healthCheckMaxFailed = c.healthCheck.maxFailed
form.healthCheckIntervalSeconds = c.healthCheck.intervalSeconds
form.healthCheckPath = c.healthCheck.path || ''
form.healthCheckHTTPHeaders = c.healthCheck.httpHeaders || []
}
// Metadata
if (c.metadatas) {
form.metadatas = Object.entries(c.metadatas).map(([key, value]) => ({
key,
value: String(value),
}))
}
// Annotations
if (c.annotations) {
form.annotations = Object.entries(c.annotations).map(([key, value]) => ({
key,
value: String(value),
}))
}
// Type-specific fields
form.remotePort = c.remotePort
// Domain config
if (Array.isArray(c.customDomains)) {
form.customDomains = c.customDomains.join(', ')
} else if (c.customDomains) {
form.customDomains = c.customDomains
}
form.subdomain = c.subdomain || ''
// HTTP specific
if (Array.isArray(c.locations)) {
form.locations = c.locations.join(', ')
} else if (c.locations) {
form.locations = c.locations
}
form.httpUser = c.httpUser || ''
form.httpPassword = c.httpPassword || ''
form.hostHeaderRewrite = c.hostHeaderRewrite || ''
form.routeByHTTPUser = c.routeByHTTPUser || ''
// Header operations
if (c.requestHeaders?.set) {
form.requestHeaders = Object.entries(c.requestHeaders.set).map(
([key, value]) => ({ key, value: String(value) }),
)
}
if (c.responseHeaders?.set) {
form.responseHeaders = Object.entries(c.responseHeaders.set).map(
([key, value]) => ({ key, value: String(value) }),
)
}
// TCPMux
form.multiplexer = c.multiplexer || 'httpconnect'
// Secure types
form.secretKey = c.secretKey || ''
if (Array.isArray(c.allowUsers)) {
form.allowUsers = c.allowUsers.join(', ')
} else if (c.allowUsers) {
form.allowUsers = c.allowUsers
}
// XTCP NAT traversal
form.natTraversalDisableAssistedAddrs =
c.natTraversal?.disableAssistedAddrs || false
return form
}
export function storeVisitorToForm(
config: StoreVisitorConfig,
): VisitorFormData {
const c = config.config || {}
const form = createDefaultVisitorForm()
form.name = config.name || ''
form.type = (config.type as VisitorType) || 'stcp'
form.enabled = c.enabled !== false
// Transport
if (c.transport) {
form.useEncryption = c.transport.useEncryption || false
form.useCompression = c.transport.useCompression || false
}
// Base fields
form.secretKey = c.secretKey || ''
form.serverUser = c.serverUser || ''
form.serverName = c.serverName || ''
form.bindAddr = c.bindAddr || '127.0.0.1'
form.bindPort = c.bindPort
// XTCP specific
form.protocol = c.protocol || 'quic'
form.keepTunnelOpen = c.keepTunnelOpen || false
form.maxRetriesAnHour = c.maxRetriesAnHour
form.minRetryInterval = c.minRetryInterval
form.fallbackTo = c.fallbackTo || ''
form.fallbackTimeoutMs = c.fallbackTimeoutMs
form.natTraversalDisableAssistedAddrs =
c.natTraversal?.disableAssistedAddrs || false
return form
}

View File

@@ -48,6 +48,28 @@
>
</div>
<div class="header-actions">
<el-select
v-model="filterSource"
placeholder="Source"
clearable
class="filter-select"
>
<el-option label="Config" value="config" />
<el-option label="Store" value="store" />
</el-select>
<el-select
v-model="filterType"
placeholder="Type"
clearable
class="filter-select"
>
<el-option
v-for="type in availableTypes"
:key="type"
:label="type.toUpperCase()"
:value="type"
/>
</el-select>
<el-input
v-model="searchText"
placeholder="Search..."
@@ -58,6 +80,18 @@
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-tooltip
v-if="storeEnabled"
content="Add new proxy"
placement="top"
>
<el-button
type="primary"
:icon="Plus"
circle
@click="handleCreate"
/>
</el-tooltip>
</div>
</div>
</template>
@@ -68,10 +102,46 @@
v-for="proxy in filteredStatus"
:key="proxy.name"
:proxy="proxy"
@edit="handleEdit"
@delete="handleDelete"
/>
</div>
<div v-else-if="!loading" class="empty-state">
<el-empty description="No proxies found" />
<div class="empty-content">
<div class="empty-icon">
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="8"
y="16"
width="48"
height="32"
rx="4"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="20" cy="32" r="4" fill="currentColor" />
<circle cx="32" cy="32" r="4" fill="currentColor" />
<circle cx="44" cy="32" r="4" fill="currentColor" />
</svg>
</div>
<p class="empty-text">No proxies configured</p>
<p class="empty-hint">
Add proxies in your configuration file or use Store to create
dynamic proxies
</p>
<el-button
v-if="storeEnabled"
type="primary"
:icon="Plus"
@click="handleCreate"
>
Create First Proxy
</el-button>
</div>
</div>
</div>
</el-card>
@@ -90,7 +160,9 @@
v-for="(count, type) in proxyTypeCounts"
:key="type"
class="proxy-type-item"
:class="{ active: filterType === type }"
v-show="count > 0"
@click="toggleTypeFilter(String(type))"
>
<div class="proxy-type-name">
{{ String(type).toUpperCase() }}
@@ -125,24 +197,139 @@
</div>
</div>
</el-card>
<!-- Store Status Card -->
<el-card class="store-status-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Store</span>
<el-tag
size="small"
:type="storeEnabled ? 'success' : 'info'"
effect="plain"
>
{{ storeEnabled ? 'Enabled' : 'Disabled' }}
</el-tag>
</div>
</template>
<div class="store-info">
<template v-if="storeEnabled">
<div class="store-stat">
<span class="store-stat-label">Store Proxies</span>
<span class="store-stat-value">{{ storeProxies.length }}</span>
</div>
<div class="store-stat">
<span class="store-stat-label">Store Visitors</span>
<span class="store-stat-value">{{ storeVisitors.length }}</span>
</div>
<p class="store-hint">
Proxies from Store are marked with a purple indicator
</p>
</template>
<template v-else>
<p class="store-disabled-text">
Enable Store in your configuration to dynamically manage proxies
</p>
</template>
</div>
</el-card>
</el-col>
</el-row>
<!-- Store Visitors Section -->
<el-row v-if="storeEnabled && storeVisitors.length > 0" :gutter="20">
<el-col :span="24">
<el-card class="visitors-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="card-title">Store Visitors</span>
<el-tag size="small" type="info">{{ storeVisitors.length }} visitors</el-tag>
</div>
<el-tooltip content="Add new visitor" placement="top">
<el-button
type="primary"
:icon="Plus"
circle
@click="handleCreateVisitor"
/>
</el-tooltip>
</div>
</template>
<div class="visitor-list">
<div
v-for="visitor in storeVisitors"
:key="visitor.name"
class="visitor-card"
>
<div class="visitor-card-header">
<div class="visitor-info">
<span class="visitor-name">{{ visitor.name }}</span>
<el-tag size="small" type="info">{{ visitor.type.toUpperCase() }}</el-tag>
</div>
<div class="visitor-actions">
<el-button size="small" @click="handleEditVisitor(visitor)">
Edit
</el-button>
<el-button
size="small"
type="danger"
@click="handleDeleteVisitor(visitor.name)"
>
Delete
</el-button>
</div>
</div>
<div class="visitor-card-body">
<span v-if="visitor.config?.serverName">
Server: {{ visitor.config.serverName }}
</span>
<span v-if="visitor.config?.bindAddr || visitor.config?.bindPort">
Bind: {{ visitor.config.bindAddr || '127.0.0.1' }}:{{ visitor.config.bindPort }}
</span>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getStatus } from '../api/frpc'
import type { ProxyStatus } from '../types/proxy'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import {
getStatus,
listStoreProxies,
deleteStoreProxy,
listStoreVisitors,
deleteStoreVisitor,
} from '../api/frpc'
import type {
ProxyStatus,
StoreProxyConfig,
StoreVisitorConfig,
} from '../types/proxy'
import StatCard from '../components/StatCard.vue'
import ProxyCard from '../components/ProxyCard.vue'
const router = useRouter()
// State
const status = ref<ProxyStatus[]>([])
const storeProxies = ref<StoreProxyConfig[]>([])
const storeVisitors = ref<StoreVisitorConfig[]>([])
const storeEnabled = ref(false)
const loading = ref(false)
const searchText = ref('')
const filterSource = ref('')
const filterType = ref('')
// Computed
const stats = computed(() => {
const total = status.value.length
const running = status.value.filter((p) => p.status === 'running').length
@@ -163,41 +350,164 @@ const hasActiveProxies = computed(() => {
return status.value.length > 0
})
const filteredStatus = computed(() => {
if (!searchText.value) {
return status.value
}
const search = searchText.value.toLowerCase()
return status.value.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search) ||
p.local_addr.toLowerCase().includes(search) ||
p.remote_addr.toLowerCase().includes(search),
)
const availableTypes = computed(() => {
const types = new Set<string>()
status.value.forEach((p) => types.add(p.type))
return Array.from(types).sort()
})
const filteredStatus = computed(() => {
let result = status.value
if (filterSource.value) {
if (filterSource.value === 'store') {
result = result.filter((p) => p.source === 'store')
} else {
result = result.filter((p) => !p.source || p.source !== 'store')
}
}
if (filterType.value) {
result = result.filter((p) => p.type === filterType.value)
}
if (searchText.value) {
const search = searchText.value.toLowerCase()
result = result.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search) ||
p.local_addr.toLowerCase().includes(search) ||
p.remote_addr.toLowerCase().includes(search),
)
}
return result
})
// Methods
const toggleTypeFilter = (type: string) => {
filterType.value = filterType.value === type ? '' : type
}
const fetchStatus = async () => {
try {
const json = await getStatus()
const list: ProxyStatus[] = []
for (const key in json) {
for (const ps of json[key]) {
list.push(ps)
}
}
status.value = list
} catch (err: any) {
ElMessage.error('Failed to get status: ' + err.message)
}
}
const fetchStoreProxies = async () => {
try {
const res = await listStoreProxies()
storeProxies.value = res.proxies || []
storeEnabled.value = true
} catch (err: any) {
if (err.status === 404) {
storeEnabled.value = false
storeProxies.value = []
} else {
console.error('Failed to fetch store proxies:', err)
}
}
}
const fetchStoreVisitors = async () => {
try {
const res = await listStoreVisitors()
storeVisitors.value = res.visitors || []
} catch (err: any) {
if (err.status === 404) {
storeVisitors.value = []
} else {
console.error('Failed to fetch store visitors:', err)
}
}
}
const fetchData = async () => {
loading.value = true
try {
const json = await getStatus()
status.value = []
for (const key in json) {
for (const ps of json[key]) {
status.value.push(ps)
}
}
} catch (err: any) {
ElMessage({
showClose: true,
message: 'Get status info from frpc failed! ' + err.message,
type: 'warning',
})
await fetchStoreProxies()
await fetchStoreVisitors()
await fetchStatus()
} finally {
loading.value = false
}
}
const handleCreate = () => {
router.push('/proxies/create')
}
const handleEdit = (proxy: ProxyStatus) => {
if (proxy.source !== 'store') return
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
}
const handleDelete = (proxy: ProxyStatus) => {
if (proxy.source !== 'store') return
ElMessageBox.confirm(
`Are you sure you want to delete "${proxy.name}"? This action cannot be undone.`,
'Delete Proxy',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning',
confirmButtonClass: 'el-button--danger',
},
).then(async () => {
try {
await deleteStoreProxy(proxy.name)
ElMessage.success('Proxy deleted')
fetchData()
} catch (err: any) {
ElMessage.error('Delete failed: ' + err.message)
}
})
}
const handleCreateVisitor = () => {
router.push('/visitors/create')
}
const handleEditVisitor = (visitor: StoreVisitorConfig) => {
router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit')
}
const handleDeleteVisitor = async (name: string) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to delete visitor "${name}"? This action cannot be undone.`,
'Delete Visitor',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning',
confirmButtonClass: 'el-button--danger',
}
)
await deleteStoreVisitor(name)
ElMessage.success('Visitor deleted')
fetchData()
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
}
}
}
// Initial load
fetchData()
</script>
@@ -222,19 +532,22 @@ fetchData()
.proxy-list-card,
.types-card,
.status-summary-card {
.status-summary-card,
.store-status-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
}
html.dark .proxy-list-card,
html.dark .types-card,
html.dark .status-summary-card {
html.dark .status-summary-card,
html.dark .store-status-card {
border-color: #3a3d5c;
background: #27293d;
}
.status-summary-card {
.status-summary-card,
.store-status-card {
margin-top: 20px;
}
@@ -255,7 +568,7 @@ html.dark .status-summary-card {
.header-actions {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
}
.card-title {
@@ -268,8 +581,12 @@ html.dark .card-title {
color: #e5e7eb;
}
.filter-select {
width: 100px;
}
.search-input {
width: 200px;
width: 180px;
}
.proxy-list-content {
@@ -282,8 +599,45 @@ html.dark .card-title {
gap: 12px;
}
/* Empty State */
.empty-state {
padding: 40px 0;
padding: 48px 24px;
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.empty-icon {
width: 80px;
height: 80px;
margin-bottom: 20px;
color: #c0c4cc;
}
html.dark .empty-icon {
color: #4b5563;
}
.empty-text {
font-size: 16px;
font-weight: 500;
color: #606266;
margin: 0 0 8px;
}
html.dark .empty-text {
color: #9ca3af;
}
.empty-hint {
font-size: 14px;
color: #909399;
margin: 0 0 20px;
max-width: 320px;
}
/* Proxy Types Grid */
@@ -303,6 +657,7 @@ html.dark .card-title {
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
}
.proxy-type-item:hover {
@@ -310,6 +665,19 @@ html.dark .card-title {
transform: translateY(-2px);
}
.proxy-type-item.active {
background: var(--el-color-primary-light-8);
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
.proxy-type-item.active .proxy-type-name {
color: var(--el-color-primary);
}
.proxy-type-item.active .proxy-type-count {
color: var(--el-color-primary);
}
html.dark .proxy-type-item {
background: #1e1e2d;
}
@@ -318,6 +686,11 @@ html.dark .proxy-type-item:hover {
background: #2a2a3c;
}
html.dark .proxy-type-item.active {
background: var(--el-color-primary-dark-2);
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.proxy-type-name {
font-size: 11px;
color: #909399;
@@ -410,6 +783,150 @@ html.dark .status-item:hover {
color: var(--el-text-color-primary);
}
/* Store Status Card */
.store-info {
min-height: 60px;
}
.store-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.08) 0%,
rgba(118, 75, 162, 0.08) 100%
);
border-radius: 8px;
margin-bottom: 12px;
}
html.dark .store-stat {
background: linear-gradient(
135deg,
rgba(129, 140, 248, 0.12) 0%,
rgba(167, 139, 250, 0.12) 100%
);
}
.store-stat-label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
html.dark .store-stat-label {
color: #9ca3af;
}
.store-stat-value {
font-size: 24px;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
html.dark .store-stat-value {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.store-hint {
font-size: 12px;
color: #909399;
margin: 0;
line-height: 1.5;
}
.store-disabled-text {
font-size: 13px;
color: #909399;
margin: 0;
line-height: 1.6;
}
/* Visitors Card */
.visitors-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
margin-top: 20px;
}
html.dark .visitors-card {
border-color: #3a3d5c;
background: #27293d;
}
.visitor-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.visitor-card {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
}
.visitor-card:hover {
background: #f0f2f5;
}
html.dark .visitor-card {
background: #1e1e2d;
}
html.dark .visitor-card:hover {
background: #2a2a3c;
}
.visitor-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.visitor-info {
display: flex;
align-items: center;
gap: 12px;
}
.visitor-name {
font-size: 15px;
font-weight: 600;
color: #303133;
}
html.dark .visitor-name {
color: #e5e7eb;
}
.visitor-actions {
display: flex;
gap: 8px;
}
.visitor-card-body {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: #606266;
}
html.dark .visitor-card-body {
color: #9ca3af;
}
@media (max-width: 768px) {
.card-header {
flex-direction: column;
@@ -435,7 +952,8 @@ html.dark .status-item:hover {
}
@media (max-width: 992px) {
.status-summary-card {
.status-summary-card,
.store-status-card {
margin-top: 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,518 @@
<template>
<div class="visitor-edit-page">
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a class="breadcrumb-link" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</a>
<router-link to="/" class="breadcrumb-item">Overview</router-link>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{ isEditing ? 'Edit Visitor' : 'Create Visitor' }}</span>
</nav>
<div v-loading="pageLoading" class="edit-content">
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
@submit.prevent
>
<!-- Header Card -->
<div class="form-card header-card">
<div class="card-body">
<div class="field-row three-col">
<el-form-item label="Name" prop="name" class="field-grow">
<el-input
v-model="form.name"
:disabled="isEditing"
placeholder="my-visitor"
/>
</el-form-item>
<el-form-item label="Type" prop="type">
<el-select
v-model="form.type"
:disabled="isEditing"
:fit-input-width="false"
popper-class="visitor-type-dropdown"
class="type-select"
>
<el-option value="stcp" label="STCP">
<div class="type-option">
<span class="type-tag-inline type-stcp">STCP</span>
<span class="type-desc">Secure TCP Visitor</span>
</div>
</el-option>
<el-option value="sudp" label="SUDP">
<div class="type-option">
<span class="type-tag-inline type-sudp">SUDP</span>
<span class="type-desc">Secure UDP Visitor</span>
</div>
</el-option>
<el-option value="xtcp" label="XTCP">
<div class="type-option">
<span class="type-tag-inline type-xtcp">XTCP</span>
<span class="type-desc">P2P (NAT traversal)</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="Enabled">
<el-switch v-model="form.enabled" />
</el-form-item>
</div>
</div>
</div>
<!-- Connection -->
<div class="form-card">
<div class="card-header">
<h3 class="card-title">Connection</h3>
</div>
<div class="card-body">
<div class="field-row two-col">
<el-form-item label="Server Name" prop="serverName">
<el-input v-model="form.serverName" placeholder="Name of the proxy to visit" />
</el-form-item>
<el-form-item label="Server User">
<el-input v-model="form.serverUser" placeholder="Leave empty for same user" />
</el-form-item>
</div>
<el-form-item label="Secret Key">
<el-input v-model="form.secretKey" type="password" show-password placeholder="Shared secret" />
</el-form-item>
<div class="field-row two-col">
<el-form-item label="Bind Address">
<el-input v-model="form.bindAddr" placeholder="127.0.0.1" />
</el-form-item>
<el-form-item label="Bind Port" prop="bindPort">
<el-input-number
v-model="form.bindPort"
:min="1"
:max="65535"
controls-position="right"
class="full-width"
/>
</el-form-item>
</div>
</div>
</div>
<!-- Transport Options (collapsible) -->
<div class="form-card collapsible-card">
<div class="card-header clickable" @click="transportExpanded = !transportExpanded">
<h3 class="card-title">Transport Options</h3>
<el-icon class="collapse-icon" :class="{ expanded: transportExpanded }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="transportExpanded" class="card-body">
<div class="field-row two-col">
<el-form-item label="Use Encryption">
<el-switch v-model="form.useEncryption" />
</el-form-item>
<el-form-item label="Use Compression">
<el-switch v-model="form.useCompression" />
</el-form-item>
</div>
</div>
</el-collapse-transition>
</div>
<!-- XTCP Options (collapsible, xtcp only) -->
<template v-if="form.type === 'xtcp'">
<div class="form-card collapsible-card">
<div class="card-header clickable" @click="xtcpExpanded = !xtcpExpanded">
<h3 class="card-title">XTCP Options</h3>
<el-icon class="collapse-icon" :class="{ expanded: xtcpExpanded }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="xtcpExpanded" class="card-body">
<el-form-item label="Protocol">
<el-select v-model="form.protocol" class="full-width">
<el-option value="quic" label="QUIC" />
<el-option value="kcp" label="KCP" />
</el-select>
</el-form-item>
<el-form-item label="Keep Tunnel Open">
<el-switch v-model="form.keepTunnelOpen" />
</el-form-item>
<div class="field-row two-col">
<el-form-item label="Max Retries per Hour">
<el-input-number v-model="form.maxRetriesAnHour" :min="0" controls-position="right" class="full-width" />
</el-form-item>
<el-form-item label="Min Retry Interval (s)">
<el-input-number v-model="form.minRetryInterval" :min="0" controls-position="right" class="full-width" />
</el-form-item>
</div>
<div class="field-row two-col">
<el-form-item label="Fallback To">
<el-input v-model="form.fallbackTo" placeholder="Fallback visitor name" />
</el-form-item>
<el-form-item label="Fallback Timeout (ms)">
<el-input-number v-model="form.fallbackTimeoutMs" :min="0" controls-position="right" class="full-width" />
</el-form-item>
</div>
</div>
</el-collapse-transition>
</div>
<!-- NAT Traversal (collapsible, xtcp only) -->
<div class="form-card collapsible-card">
<div class="card-header clickable" @click="natExpanded = !natExpanded">
<h3 class="card-title">NAT Traversal</h3>
<el-icon class="collapse-icon" :class="{ expanded: natExpanded }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="natExpanded" class="card-body">
<el-form-item label="Disable Assisted Addresses">
<el-switch v-model="form.natTraversalDisableAssistedAddrs" />
<div class="form-tip">Only use STUN-discovered public addresses</div>
</el-form-item>
</div>
</el-collapse-transition>
</div>
</template>
</el-form>
</div>
<!-- Sticky Footer -->
<div class="sticky-footer">
<div class="footer-content">
<el-button @click="goBack">Cancel</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ isEditing ? 'Update' : 'Create' }}
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import {
type VisitorFormData,
createDefaultVisitorForm,
formToStoreVisitor,
storeVisitorToForm,
} from '../types/proxy'
import {
getStoreVisitor,
createStoreVisitor,
updateStoreVisitor,
} from '../api/frpc'
const route = useRoute()
const router = useRouter()
const isEditing = computed(() => !!route.params.name)
const pageLoading = ref(false)
const saving = ref(false)
const formRef = ref<FormInstance>()
const form = ref<VisitorFormData>(createDefaultVisitorForm())
const transportExpanded = ref(false)
const xtcpExpanded = ref(false)
const natExpanded = ref(false)
const formRules: FormRules = {
name: [
{ required: true, message: 'Name is required', trigger: 'blur' },
{ min: 1, max: 50, message: 'Length should be 1 to 50', trigger: 'blur' },
],
type: [{ required: true, message: 'Type is required', trigger: 'change' }],
serverName: [
{ required: true, message: 'Server name is required', trigger: 'blur' },
],
bindPort: [
{ required: true, message: 'Bind port is required', trigger: 'blur' },
{ type: 'number', min: 1, message: 'Port must be greater than 0', trigger: 'blur' },
],
}
const goBack = () => {
router.push('/')
}
const loadVisitor = async () => {
const name = route.params.name as string
if (!name) return
pageLoading.value = true
try {
const res = await getStoreVisitor(name)
form.value = storeVisitorToForm(res)
} catch (err: any) {
ElMessage.error('Failed to load visitor: ' + err.message)
router.push('/')
} finally {
pageLoading.value = false
}
}
const handleSave = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
ElMessage.warning('Please fix the form errors')
return
}
saving.value = true
try {
const data = formToStoreVisitor(form.value)
if (isEditing.value) {
await updateStoreVisitor(form.value.name, data)
ElMessage.success('Visitor updated')
} else {
await createStoreVisitor(data)
ElMessage.success('Visitor created')
}
router.push('/')
} catch (err: any) {
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
} finally {
saving.value = false
}
}
onMounted(() => {
if (isEditing.value) {
loadVisitor()
}
})
</script>
<style scoped>
.visitor-edit-page {
padding-bottom: 80px;
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
margin-bottom: 24px;
}
.breadcrumb-link {
display: flex;
align-items: center;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
margin-right: 4px;
}
.breadcrumb-link:hover {
color: var(--text-primary);
}
.breadcrumb-item {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: var(--el-color-primary);
}
.breadcrumb-separator {
color: var(--el-border-color);
}
.breadcrumb-current {
color: var(--text-primary);
font-weight: 500;
}
/* Form Cards */
.form-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
html.dark .form-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--header-border);
}
html.dark .card-header {
border-bottom-color: #3a3d5c;
}
.card-header.clickable {
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.card-header.clickable:hover {
background: var(--hover-bg);
}
.collapsible-card .card-header {
border-bottom: none;
}
.collapsible-card .card-body {
border-top: 1px solid var(--header-border);
}
html.dark .collapsible-card .card-body {
border-top-color: #3a3d5c;
}
.card-title {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.collapse-icon {
transition: transform 0.3s;
color: var(--text-secondary);
}
.collapse-icon.expanded {
transform: rotate(-180deg);
}
.card-body {
padding: 20px 24px;
}
/* Field Rows */
.field-row {
display: grid;
gap: 16px;
}
.field-row.two-col {
grid-template-columns: 1fr 1fr;
}
.field-row.three-col {
grid-template-columns: 1fr auto auto;
align-items: start;
}
.field-grow {
min-width: 0;
}
.full-width {
width: 100%;
}
.type-select {
width: 180px;
}
.type-option {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.type-tag-inline {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.5px;
}
.type-tag-inline.type-stcp,
.type-tag-inline.type-sudp,
.type-tag-inline.type-xtcp {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.type-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
/* Sticky Footer */
.sticky-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 99;
background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid var(--header-border);
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 16px 40px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Responsive */
@media (max-width: 768px) {
.field-row.two-col,
.field-row.three-col {
grid-template-columns: 1fr;
}
.type-select {
width: 100%;
}
.card-body {
padding: 16px;
}
.footer-content {
padding: 12px 20px;
}
}
</style>
<style>
.visitor-type-dropdown {
min-width: 300px !important;
}
.visitor-type-dropdown .el-select-dropdown__item {
height: auto;
padding: 8px 16px;
line-height: 1.4;
}
</style>

View File

@@ -13,6 +13,7 @@
<span v-if="client.hostname" class="hostname-badge">{{
client.hostname
}}</span>
<el-tag v-if="client.version" size="small" type="success">v{{ client.version }}</el-tag>
</div>
<div class="card-meta">

View File

@@ -3,6 +3,7 @@ export interface ClientInfoData {
user: string
clientID: string
runID: string
version?: string
hostname: string
clientIP?: string
metas?: Record<string, string>

View File

@@ -3,7 +3,6 @@ export interface ProxyStatsInfo {
conf: any
user: string
clientID: string
clientVersion: string
todayTrafficIn: number
todayTrafficOut: number
curConns: number

View File

@@ -6,6 +6,7 @@ export class Client {
user: string
clientID: string
runID: string
version: string
hostname: string
ip: string
metas: Map<string, string>
@@ -19,6 +20,7 @@ export class Client {
this.user = data.user
this.clientID = data.clientID
this.runID = data.runID
this.version = data.version || ''
this.hostname = data.hostname
this.ip = data.clientIP || ''
this.metas = new Map<string, string>()

View File

@@ -12,7 +12,6 @@ class BaseProxy {
status: string
user: string
clientID: string
clientVersion: string
addr: string
port: number
@@ -49,7 +48,6 @@ class BaseProxy {
this.status = proxyStats.status
this.user = proxyStats.user || ''
this.clientID = proxyStats.clientID || ''
this.clientVersion = proxyStats.clientVersion
this.addr = ''
this.port = 0

View File

@@ -22,7 +22,10 @@
{{ client.displayName.charAt(0).toUpperCase() }}
</div>
<div class="client-info">
<h1 class="client-name">{{ client.displayName }}</h1>
<div class="client-name-row">
<h1 class="client-name">{{ client.displayName }}</h1>
<el-tag v-if="client.version" size="small" type="success">v{{ client.version }}</el-tag>
</div>
<div class="client-meta">
<span v-if="client.ip" class="meta-item">{{
client.ip
@@ -354,11 +357,18 @@ onMounted(() => {
min-width: 0;
}
.client-name-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.client-name {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 4px 0;
margin: 0;
line-height: 1.3;
}