diff --git a/.circleci/config.yml b/.circleci/config.yml
index 817e1297..21f4159d 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2,7 +2,7 @@ version: 2
jobs:
go-version-latest:
docker:
- - image: cimg/go:1.24-node
+ - image: cimg/go:1.25-node
resource_class: large
steps:
- checkout
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index e8cc1fe9..4e42f0fe 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -17,19 +17,19 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: '1.24'
+ go-version: '1.25'
cache: false
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build web assets (frps)
- run: make install build
+ run: make build
working-directory: web/frps
- name: Build web assets (frpc)
- run: make install build
+ run: make build
working-directory: web/frpc
- name: golangci-lint
- uses: golangci/golangci-lint-action@v8
+ uses: golangci/golangci-lint-action@v9
with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
- version: v2.3
+ version: v2.10
diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml
index 502d0032..ebc0dca9 100644
--- a/.github/workflows/goreleaser.yml
+++ b/.github/workflows/goreleaser.yml
@@ -15,15 +15,15 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '1.24'
+ go-version: '1.25'
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build web assets (frps)
- run: make install build
+ run: make build
working-directory: web/frps
- name: Build web assets (frpc)
- run: make install build
+ run: make build
working-directory: web/frpc
- name: Make All
run: |
diff --git a/.gitignore b/.gitignore
index c6480f59..1054d93f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
@@ -37,8 +25,12 @@ dist/
client.crt
client.key
+node_modules/
+
# Cache
*.swp
# AI
-CLAUDE.md
+.claude/
+.sisyphus/
+.superpowers/
diff --git a/.golangci.yml b/.golangci.yml
index f7c0e8bd..89222745 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -18,6 +18,7 @@ linters:
- lll
- makezero
- misspell
+ - modernize
- prealloc
- predeclared
- revive
@@ -33,13 +34,7 @@ linters:
disabled-checks:
- exitAfterDefer
gosec:
- excludes:
- - G401
- - G402
- - G404
- - G501
- - G115
- - G204
+ excludes: ["G115", "G117", "G204", "G401", "G402", "G404", "G501", "G703", "G704", "G705"]
severity: low
confidence: low
govet:
@@ -53,6 +48,9 @@ linters:
ignore-rules:
- cancelled
- marshalled
+ modernize:
+ disable:
+ - omitzero
unparam:
check-exported: false
exclusions:
@@ -77,6 +75,9 @@ linters:
- linters:
- revive
text: "avoid meaningless package names"
+ - linters:
+ - revive
+ text: "Go standard library package names"
- linters:
- unparam
text: is always false
@@ -89,6 +90,7 @@ linters:
- third_party$
- builtin$
- examples$
+ - node_modules
formatters:
enable:
- gci
@@ -111,6 +113,7 @@ formatters:
- third_party$
- builtin$
- examples$
+ - node_modules
issues:
max-issues-per-linter: 0
max-same-issues: 0
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..23f0e734
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,39 @@
+# AGENTS.md
+
+## Development Commands
+
+### Build
+- `make build` - Build both frps and frpc binaries
+- `make frps` - Build server binary only
+- `make frpc` - Build client binary only
+- `make all` - Build everything with formatting
+
+### Testing
+- `make test` - Run unit tests
+- `make e2e` - Run end-to-end tests
+- `make e2e-trace` - Run e2e tests with trace logging
+- `make alltest` - Run all tests including vet, unit tests, and e2e
+
+### Code Quality
+- `make fmt` - Run go fmt
+- `make fmt-more` - Run gofumpt for more strict formatting
+- `make gci` - Run gci import organizer
+- `make vet` - Run go vet
+- `golangci-lint run` - Run comprehensive linting (configured in .golangci.yml)
+
+### Assets
+- `make web` - Build web dashboards (frps and frpc)
+
+### Cleanup
+- `make clean` - Remove built binaries and temporary files
+
+## Testing
+
+- E2E tests using Ginkgo/Gomega framework
+- Mock servers in `/test/e2e/mock/`
+- Run: `make e2e` or `make alltest`
+
+## Agent Runbooks
+
+Operational procedures for agents are in `doc/agents/`:
+- `doc/agents/release.md` - Release process
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 00000000..47dc3e3d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/Makefile b/Makefile
index a4df78f7..26e40b80 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,7 @@
export PATH := $(PATH):`go env GOPATH`/bin
export GO111MODULE=on
LDFLAGS := -s -w
+NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',noweb')
.PHONY: web frps-web frpc-web frps frpc
@@ -28,23 +29,23 @@ fmt-more:
gci:
gci write -s standard -s default -s "prefix(github.com/fatedier/frp/)" ./
-vet: web
- go vet ./...
+vet:
+ go vet -tags "$(NOWEB_TAG)" ./...
frps:
- env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags frps -o bin/frps ./cmd/frps
+ env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags "frps$(NOWEB_TAG)" -o bin/frps ./cmd/frps
frpc:
- env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags frpc -o bin/frpc ./cmd/frpc
+ env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags "frpc$(NOWEB_TAG)" -o bin/frpc ./cmd/frpc
test: gotest
-gotest: web
- go test -v --cover ./assets/...
- go test -v --cover ./cmd/...
- go test -v --cover ./client/...
- go test -v --cover ./server/...
- go test -v --cover ./pkg/...
+gotest:
+ go test -tags "$(NOWEB_TAG)" -v --cover ./assets/...
+ go test -tags "$(NOWEB_TAG)" -v --cover ./cmd/...
+ go test -tags "$(NOWEB_TAG)" -v --cover ./client/...
+ go test -tags "$(NOWEB_TAG)" -v --cover ./server/...
+ go test -tags "$(NOWEB_TAG)" -v --cover ./pkg/...
e2e:
./hack/run-e2e.sh
diff --git a/README.md b/README.md
index ac4591c2..a080b431 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,16 @@ frp is an open source project with its ongoing development made possible entirel
Gold Sponsors
+
+
+## Recall.ai - API for meeting recordings
+
+If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
+
+an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
+
+
+
@@ -40,15 +50,6 @@ frp is an open source project with its ongoing development made possible entirel
An open source, self-hosted alternative to public clouds, built for data ownership and privacy
-
-
-## Recall.ai - API for meeting recordings
-
-If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
-
-an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
-
-
## What is frp?
@@ -800,6 +801,14 @@ Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to l
**Note that global client parameters won't be modified except 'start'.**
+`start` is a global allowlist evaluated after all sources are merged (config file/include/store).
+If `start` is non-empty, any proxy or visitor not listed there will not be started, including
+entries created via Store API.
+
+`start` is kept mainly for compatibility and is generally not recommended for new configurations.
+Prefer per-proxy/per-visitor `enabled`, and keep `start` empty unless you explicitly want this
+global allowlist behavior.
+
You can run command `frpc verify -c ./frpc.toml` before reloading to check if there are config errors.
### Get proxy status from client
diff --git a/README_zh.md b/README_zh.md
index 210bfb0e..1b4b6862 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
Gold Sponsors
+
+
+## Recall.ai - API for meeting recordings
+
+If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
+
+an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
+
+
+
@@ -42,15 +52,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
An open source, self-hosted alternative to public clouds, built for data ownership and privacy
-
-
-## Recall.ai - API for meeting recordings
-
-If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
-
-an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
-
-
## 为什么使用 frp ?
diff --git a/Release.md b/Release.md
index 69032dcc..9bee09dc 100644
--- a/Release.md
+++ b/Release.md
@@ -1,8 +1,10 @@
## Features
-* frpc now supports a `clientID` option to uniquely identify client instances. The server dashboard displays all connected clients with their online/offline status, connection history, and metadata, making it easier to monitor and manage multiple frpc deployments.
-* Redesigned the frp web dashboard with a modern UI, dark mode support, and improved navigation.
+* Added a built-in `store` capability for frpc, including persisted store source (`[store] path = "..."`), Store CRUD admin APIs (`/api/store/proxies*`, `/api/store/visitors*`) with runtime reload, and Store management pages in the frpc web dashboard.
-## Fixes
+## Improvements
-* Fixed UDP proxy protocol sending header on every packet instead of only the first packet of each session.
+* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
+* Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded.
+* Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers.
+* OIDC auth now caches the access token and refreshes it before expiry, avoiding a new token request on every heartbeat. Falls back to per-request fetch when the provider omits `expires_in`.
diff --git a/assets/assets.go b/assets/assets.go
index 2d7a164e..713087ad 100644
--- a/assets/assets.go
+++ b/assets/assets.go
@@ -29,14 +29,23 @@ var (
prefixPath string
)
+type emptyFS struct{}
+
+func (emptyFS) Open(name string) (http.File, error) {
+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
+}
+
// if path is empty, load assets in memory
// or set FileSystem using disk files
func Load(path string) {
prefixPath = path
- if prefixPath != "" {
+ switch {
+ case prefixPath != "":
FileSystem = http.Dir(prefixPath)
- } else {
+ case content != nil:
FileSystem = http.FS(content)
+ default:
+ FileSystem = emptyFS{}
}
}
diff --git a/client/api/controller.go b/client/api/controller.go
deleted file mode 100644
index 6874b724..00000000
--- a/client/api/controller.go
+++ /dev/null
@@ -1,189 +0,0 @@
-// Copyright 2025 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 api
-
-import (
- "cmp"
- "fmt"
- "net"
- "net/http"
- "os"
- "slices"
- "strconv"
- "time"
-
- "github.com/fatedier/frp/client/proxy"
- "github.com/fatedier/frp/pkg/config"
- v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/config/v1/validation"
- "github.com/fatedier/frp/pkg/policy/security"
- httppkg "github.com/fatedier/frp/pkg/util/http"
- "github.com/fatedier/frp/pkg/util/log"
-)
-
-// 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)
-}
-
-// 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)
-}
-
-// 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,
- }
-}
-
-// Reload handles GET /api/reload
-func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
- strictConfigMode := false
- strictStr := ctx.Query("strictConfig")
- if strictStr != "" {
- strictConfigMode, _ = strconv.ParseBool(strictStr)
- }
-
- cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(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 {
- 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 {
- log.Warnf("reload frpc proxy config error: %s", err.Error())
- return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
- }
-
- log.Infof("success reload conf")
- return nil, nil
-}
-
-// Stop handles POST /api/stop
-func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
- go c.gracefulClose(100 * time.Millisecond)
- return nil, nil
-}
-
-// Status handles GET /api/status
-func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
- res := make(StatusResp)
- ps := c.getProxyStatus()
- if ps == nil {
- return res, nil
- }
-
- for _, status := range ps {
- res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))
- }
-
- for _, arrs := range res {
- if len(arrs) <= 1 {
- continue
- }
- slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
- return cmp.Compare(a.Name, b.Name)
- })
- }
- return res, nil
-}
-
-// GetConfig handles GET /api/config
-func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {
- if c.configFilePath == "" {
- return nil, httppkg.NewError(http.StatusBadRequest, "frpc has no config file path")
- }
-
- content, err := os.ReadFile(c.configFilePath)
- if err != nil {
- log.Warnf("load frpc config file error: %s", err.Error())
- return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
- }
- return string(content), nil
-}
-
-// PutConfig handles PUT /api/config
-func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
- body, err := ctx.Body()
- if err != nil {
- return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err))
- }
-
- if len(body) == 0 {
- return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty")
- }
-
- if err := os.WriteFile(c.configFilePath, body, 0o600); err != nil {
- return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("write content to frpc config file error: %v", err))
- }
- return nil, nil
-}
-
-// buildProxyStatusResp creates a ProxyStatusResp from proxy.WorkingStatus
-func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
- psr := ProxyStatusResp{
- Name: status.Name,
- Type: status.Type,
- Status: status.Phase,
- Err: status.Err,
- }
- baseCfg := status.Cfg.GetBaseConfig()
- if baseCfg.LocalPort != 0 {
- psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
- }
- psr.Plugin = baseCfg.Plugin.Type
-
- if status.Err == "" {
- psr.RemoteAddr = status.RemoteAddr
- if slices.Contains([]string{"tcp", "udp"}, status.Type) {
- psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
- }
- }
- return psr
-}
diff --git a/client/admin_api.go b/client/api_router.go
similarity index 55%
rename from client/admin_api.go
rename to client/api_router.go
index 09936352..82b73046 100644
--- a/client/admin_api.go
+++ b/client/api_router.go
@@ -17,7 +17,7 @@ package client
import (
"net/http"
- "github.com/fatedier/frp/client/api"
+ adminapi "github.com/fatedier/frp/client/http"
"github.com/fatedier/frp/client/proxy"
httppkg "github.com/fatedier/frp/pkg/util/http"
netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -38,6 +38,22 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
+ subRouter.HandleFunc("/api/proxy/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet)
+ subRouter.HandleFunc("/api/visitor/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet)
+
+ if svr.storeSource != nil {
+ subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
+ 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))),
@@ -51,14 +67,11 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
-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,
+func newAPIController(svr *Service) *adminapi.Controller {
+ manager := newServiceConfigManager(svr)
+ return adminapi.NewController(adminapi.ControllerParams{
+ ServerAddr: svr.common.ServerAddr,
+ Manager: manager,
})
}
diff --git a/client/config_manager.go b/client/config_manager.go
new file mode 100644
index 00000000..24512e9f
--- /dev/null
+++ b/client/config_manager.go
@@ -0,0 +1,464 @@
+package client
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/fatedier/frp/client/configmgmt"
+ "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/util/log"
+)
+
+type serviceConfigManager struct {
+ svr *Service
+}
+
+func newServiceConfigManager(svr *Service) configmgmt.ConfigManager {
+ return &serviceConfigManager{svr: svr}
+}
+
+func (m *serviceConfigManager) ReloadFromFile(strict bool) error {
+ if m.svr.configFilePath == "" {
+ return fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument)
+ }
+
+ result, err := config.LoadClientConfigResult(m.svr.configFilePath, strict)
+ if err != nil {
+ return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ proxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers(
+ result.Common,
+ result.Proxies,
+ result.Visitors,
+ )
+ proxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation)
+ visitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation)
+
+ if _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, m.svr.unsafeFeatures); err != nil {
+ return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ if err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil {
+ return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err)
+ }
+
+ log.Infof("success reload conf")
+ return nil
+}
+
+func (m *serviceConfigManager) ReadConfigFile() (string, error) {
+ if m.svr.configFilePath == "" {
+ return "", fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument)
+ }
+
+ content, err := os.ReadFile(m.svr.configFilePath)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
+ }
+ return string(content), nil
+}
+
+func (m *serviceConfigManager) WriteConfigFile(content []byte) error {
+ if len(content) == 0 {
+ return fmt.Errorf("%w: body can't be empty", configmgmt.ErrInvalidArgument)
+ }
+
+ if err := os.WriteFile(m.svr.configFilePath, content, 0o600); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
+ return m.svr.getAllProxyStatus()
+}
+
+func (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
+ // Try running proxy manager first
+ ws, ok := m.svr.getProxyStatus(name)
+ if ok {
+ return ws.Cfg, true
+ }
+
+ // Fallback to store
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+
+ if storeSource != nil {
+ cfg := storeSource.GetProxy(name)
+ if cfg != nil {
+ return cfg, true
+ }
+ }
+ return nil, false
+}
+
+func (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
+ // Try running visitor manager first
+ cfg, ok := m.svr.getVisitorCfg(name)
+ if ok {
+ return cfg, true
+ }
+
+ // Fallback to store
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+
+ if storeSource != nil {
+ vcfg := storeSource.GetVisitor(name)
+ if vcfg != nil {
+ return vcfg, true
+ }
+ }
+ return nil, false
+}
+
+func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {
+ if name == "" {
+ return false
+ }
+
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+
+ if storeSource == nil {
+ return false
+ }
+
+ cfg := storeSource.GetProxy(name)
+ if cfg == nil {
+ return false
+ }
+ enabled := cfg.GetBaseConfig().Enabled
+ return enabled == nil || *enabled
+}
+
+func (m *serviceConfigManager) StoreEnabled() bool {
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+ return storeSource != nil
+}
+
+func (m *serviceConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
+ storeSource, err := m.storeSourceOrError()
+ if err != nil {
+ return nil, err
+ }
+ return storeSource.GetAllProxies()
+}
+
+func (m *serviceConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {
+ if name == "" {
+ return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
+ }
+
+ storeSource, err := m.storeSourceOrError()
+ if err != nil {
+ return nil, err
+ }
+
+ cfg := storeSource.GetProxy(name)
+ if cfg == nil {
+ return nil, fmt.Errorf("%w: proxy %q", configmgmt.ErrNotFound, name)
+ }
+ return cfg, nil
+}
+
+func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ if err := m.validateStoreProxyConfigurer(cfg); err != nil {
+ return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ name := cfg.GetBaseConfig().Name
+ persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {
+ if err := storeSource.AddProxy(cfg); err != nil {
+ if errors.Is(err, source.ErrAlreadyExists) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ log.Infof("store: created proxy %q", name)
+ return persisted, nil
+}
+
+func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ if name == "" {
+ return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
+ }
+ if cfg == nil {
+ return nil, fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument)
+ }
+ bodyName := cfg.GetBaseConfig().Name
+ if bodyName != name {
+ return nil, fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument)
+ }
+ if err := m.validateStoreProxyConfigurer(cfg); err != nil {
+ return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {
+ if err := storeSource.UpdateProxy(cfg); err != nil {
+ if errors.Is(err, source.ErrNotFound) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ log.Infof("store: updated proxy %q", name)
+ return persisted, nil
+}
+
+func (m *serviceConfigManager) DeleteStoreProxy(name string) error {
+ if name == "" {
+ return fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
+ }
+
+ if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
+ if err := storeSource.RemoveProxy(name); err != nil {
+ if errors.Is(err, source.ErrNotFound) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
+ }
+ return err
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ log.Infof("store: deleted proxy %q", name)
+ return nil
+}
+
+func (m *serviceConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {
+ storeSource, err := m.storeSourceOrError()
+ if err != nil {
+ return nil, err
+ }
+ return storeSource.GetAllVisitors()
+}
+
+func (m *serviceConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {
+ if name == "" {
+ return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
+ }
+
+ storeSource, err := m.storeSourceOrError()
+ if err != nil {
+ return nil, err
+ }
+
+ cfg := storeSource.GetVisitor(name)
+ if cfg == nil {
+ return nil, fmt.Errorf("%w: visitor %q", configmgmt.ErrNotFound, name)
+ }
+ return cfg, nil
+}
+
+func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
+ return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ name := cfg.GetBaseConfig().Name
+ persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {
+ if err := storeSource.AddVisitor(cfg); err != nil {
+ if errors.Is(err, source.ErrAlreadyExists) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ log.Infof("store: created visitor %q", name)
+ return persisted, nil
+}
+
+func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ if name == "" {
+ return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
+ }
+ if cfg == nil {
+ return nil, fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument)
+ }
+ bodyName := cfg.GetBaseConfig().Name
+ if bodyName != name {
+ return nil, fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument)
+ }
+ if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
+ return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
+ }
+
+ persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {
+ if err := storeSource.UpdateVisitor(cfg); err != nil {
+ if errors.Is(err, source.ErrNotFound) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
+ }
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ log.Infof("store: updated visitor %q", name)
+ return persisted, nil
+}
+
+func (m *serviceConfigManager) DeleteStoreVisitor(name string) error {
+ if name == "" {
+ return fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
+ }
+
+ if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
+ if err := storeSource.RemoveVisitor(name); err != nil {
+ if errors.Is(err, source.ErrNotFound) {
+ return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
+ }
+ return err
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ log.Infof("store: deleted visitor %q", name)
+ return nil
+}
+
+func (m *serviceConfigManager) GracefulClose(d time.Duration) {
+ m.svr.GracefulClose(d)
+}
+
+func (m *serviceConfigManager) storeSourceOrError() (*source.StoreSource, error) {
+ m.svr.reloadMu.Lock()
+ storeSource := m.svr.storeSource
+ m.svr.reloadMu.Unlock()
+
+ if storeSource == nil {
+ return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
+ }
+ return storeSource, nil
+}
+
+func (m *serviceConfigManager) withStoreMutationAndReload(
+ fn func(storeSource *source.StoreSource) error,
+) error {
+ m.svr.reloadMu.Lock()
+ defer m.svr.reloadMu.Unlock()
+
+ storeSource := m.svr.storeSource
+ if storeSource == nil {
+ return fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
+ }
+
+ if err := fn(storeSource); err != nil {
+ return err
+ }
+
+ if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
+ return fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
+ }
+ return nil
+}
+
+func (m *serviceConfigManager) withStoreProxyMutationAndReload(
+ name string,
+ fn func(storeSource *source.StoreSource) error,
+) (v1.ProxyConfigurer, error) {
+ m.svr.reloadMu.Lock()
+ defer m.svr.reloadMu.Unlock()
+
+ storeSource := m.svr.storeSource
+ if storeSource == nil {
+ return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
+ }
+
+ if err := fn(storeSource); err != nil {
+ return nil, err
+ }
+ if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
+ return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
+ }
+
+ persisted := storeSource.GetProxy(name)
+ if persisted == nil {
+ return nil, fmt.Errorf("%w: proxy %q not found in store after mutation", configmgmt.ErrApplyConfig, name)
+ }
+ return persisted.Clone(), nil
+}
+
+func (m *serviceConfigManager) withStoreVisitorMutationAndReload(
+ name string,
+ fn func(storeSource *source.StoreSource) error,
+) (v1.VisitorConfigurer, error) {
+ m.svr.reloadMu.Lock()
+ defer m.svr.reloadMu.Unlock()
+
+ storeSource := m.svr.storeSource
+ if storeSource == nil {
+ return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
+ }
+
+ if err := fn(storeSource); err != nil {
+ return nil, err
+ }
+ if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
+ return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
+ }
+
+ persisted := storeSource.GetVisitor(name)
+ if persisted == nil {
+ return nil, fmt.Errorf("%w: visitor %q not found in store after mutation", configmgmt.ErrApplyConfig, name)
+ }
+ return persisted.Clone(), nil
+}
+
+func (m *serviceConfigManager) validateStoreProxyConfigurer(cfg v1.ProxyConfigurer) error {
+ if cfg == nil {
+ return fmt.Errorf("invalid proxy config")
+ }
+ runtimeCfg := cfg.Clone()
+ if runtimeCfg == nil {
+ return fmt.Errorf("invalid proxy config")
+ }
+ runtimeCfg.Complete()
+ return validation.ValidateProxyConfigurerForClient(runtimeCfg)
+}
+
+func (m *serviceConfigManager) validateStoreVisitorConfigurer(cfg v1.VisitorConfigurer) error {
+ if cfg == nil {
+ return fmt.Errorf("invalid visitor config")
+ }
+ runtimeCfg := cfg.Clone()
+ if runtimeCfg == nil {
+ return fmt.Errorf("invalid visitor config")
+ }
+ runtimeCfg.Complete()
+ return validation.ValidateVisitorConfigurer(runtimeCfg)
+}
diff --git a/client/config_manager_test.go b/client/config_manager_test.go
new file mode 100644
index 00000000..07ae3297
--- /dev/null
+++ b/client/config_manager_test.go
@@ -0,0 +1,137 @@
+package client
+
+import (
+ "errors"
+ "path/filepath"
+ "testing"
+
+ "github.com/fatedier/frp/client/configmgmt"
+ "github.com/fatedier/frp/pkg/config/source"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
+ return &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: name,
+ Type: "tcp",
+ ProxyBackend: v1.ProxyBackend{
+ LocalPort: 10080,
+ },
+ },
+ }
+}
+
+func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
+ storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
+ Path: filepath.Join(t.TempDir(), "store.json"),
+ })
+ if err != nil {
+ t.Fatalf("new store source: %v", err)
+ }
+ if err := storeSource.AddProxy(newTestRawTCPProxyConfig("p1")); err != nil {
+ t.Fatalf("seed proxy: %v", err)
+ }
+
+ agg := source.NewAggregator(source.NewConfigSource())
+ agg.SetStoreSource(storeSource)
+
+ mgr := &serviceConfigManager{
+ svr: &Service{
+ aggregator: agg,
+ configSource: agg.ConfigSource(),
+ storeSource: storeSource,
+ reloadCommon: &v1.ClientCommonConfig{},
+ },
+ }
+
+ _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
+ if err == nil {
+ t.Fatal("expected conflict error")
+ }
+ if !errors.Is(err, configmgmt.ErrConflict) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceConfigManagerCreateStoreProxyKeepsStoreOnReloadFailure(t *testing.T) {
+ storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
+ Path: filepath.Join(t.TempDir(), "store.json"),
+ })
+ if err != nil {
+ t.Fatalf("new store source: %v", err)
+ }
+
+ mgr := &serviceConfigManager{
+ svr: &Service{
+ storeSource: storeSource,
+ reloadCommon: &v1.ClientCommonConfig{},
+ },
+ }
+
+ _, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
+ if err == nil {
+ t.Fatal("expected apply config error")
+ }
+ if !errors.Is(err, configmgmt.ErrApplyConfig) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if storeSource.GetProxy("p1") == nil {
+ t.Fatal("proxy should remain in store after reload failure")
+ }
+}
+
+func TestServiceConfigManagerCreateStoreProxyStoreDisabled(t *testing.T) {
+ mgr := &serviceConfigManager{
+ svr: &Service{
+ reloadCommon: &v1.ClientCommonConfig{},
+ },
+ }
+
+ _, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
+ if err == nil {
+ t.Fatal("expected store disabled error")
+ }
+ if !errors.Is(err, configmgmt.ErrStoreDisabled) {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestServiceConfigManagerCreateStoreProxyDoesNotPersistRuntimeDefaults(t *testing.T) {
+ storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
+ Path: filepath.Join(t.TempDir(), "store.json"),
+ })
+ if err != nil {
+ t.Fatalf("new store source: %v", err)
+ }
+ agg := source.NewAggregator(source.NewConfigSource())
+ agg.SetStoreSource(storeSource)
+
+ mgr := &serviceConfigManager{
+ svr: &Service{
+ aggregator: agg,
+ configSource: agg.ConfigSource(),
+ storeSource: storeSource,
+ reloadCommon: &v1.ClientCommonConfig{},
+ },
+ }
+
+ persisted, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy"))
+ if err != nil {
+ t.Fatalf("create store proxy: %v", err)
+ }
+ if persisted == nil {
+ t.Fatal("expected persisted proxy to be returned")
+ }
+
+ got := storeSource.GetProxy("raw-proxy")
+ if got == nil {
+ t.Fatal("proxy not found in store")
+ }
+ if got.GetBaseConfig().LocalIP != "" {
+ t.Fatalf("localIP was persisted with runtime default: %q", got.GetBaseConfig().LocalIP)
+ }
+ if got.GetBaseConfig().Transport.BandwidthLimitMode != "" {
+ t.Fatalf("bandwidthLimitMode was persisted with runtime default: %q", got.GetBaseConfig().Transport.BandwidthLimitMode)
+ }
+}
diff --git a/client/configmgmt/types.go b/client/configmgmt/types.go
new file mode 100644
index 00000000..5a51a5e5
--- /dev/null
+++ b/client/configmgmt/types.go
@@ -0,0 +1,45 @@
+package configmgmt
+
+import (
+ "errors"
+ "time"
+
+ "github.com/fatedier/frp/client/proxy"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+var (
+ ErrInvalidArgument = errors.New("invalid argument")
+ ErrNotFound = errors.New("not found")
+ ErrConflict = errors.New("conflict")
+ ErrStoreDisabled = errors.New("store disabled")
+ ErrApplyConfig = errors.New("apply config failed")
+)
+
+type ConfigManager interface {
+ ReloadFromFile(strict bool) error
+
+ ReadConfigFile() (string, error)
+ WriteConfigFile(content []byte) error
+
+ GetProxyStatus() []*proxy.WorkingStatus
+ IsStoreProxyEnabled(name string) bool
+ StoreEnabled() bool
+
+ GetProxyConfig(name string) (v1.ProxyConfigurer, bool)
+ GetVisitorConfig(name string) (v1.VisitorConfigurer, bool)
+
+ ListStoreProxies() ([]v1.ProxyConfigurer, error)
+ GetStoreProxy(name string) (v1.ProxyConfigurer, error)
+ CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
+ UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
+ DeleteStoreProxy(name string) error
+
+ ListStoreVisitors() ([]v1.VisitorConfigurer, error)
+ GetStoreVisitor(name string) (v1.VisitorConfigurer, error)
+ CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
+ UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
+ DeleteStoreVisitor(name string) error
+
+ GracefulClose(d time.Duration)
+}
diff --git a/client/control.go b/client/control.go
index 0f48c36d..020ac94f 100644
--- a/client/control.go
+++ b/client/control.go
@@ -25,6 +25,7 @@ import (
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/wait"
@@ -156,6 +157,8 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
return
}
+ startMsg.ProxyName = naming.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 := naming.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)
}
}
diff --git a/client/http/controller.go b/client/http/controller.go
new file mode 100644
index 00000000..57e8165d
--- /dev/null
+++ b/client/http/controller.go
@@ -0,0 +1,433 @@
+// Copyright 2025 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "cmp"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "slices"
+ "strconv"
+ "time"
+
+ "github.com/fatedier/frp/client/configmgmt"
+ "github.com/fatedier/frp/client/http/model"
+ "github.com/fatedier/frp/client/proxy"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+ "github.com/fatedier/frp/pkg/util/jsonx"
+)
+
+// Controller handles HTTP API requests for frpc.
+type Controller struct {
+ serverAddr string
+ manager configmgmt.ConfigManager
+}
+
+// ControllerParams contains parameters for creating an APIController.
+type ControllerParams struct {
+ ServerAddr string
+ Manager configmgmt.ConfigManager
+}
+
+func NewController(params ControllerParams) *Controller {
+ return &Controller{
+ serverAddr: params.ServerAddr,
+ manager: params.Manager,
+ }
+}
+
+func (c *Controller) toHTTPError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ code := http.StatusInternalServerError
+ switch {
+ case errors.Is(err, configmgmt.ErrInvalidArgument):
+ code = http.StatusBadRequest
+ case errors.Is(err, configmgmt.ErrNotFound), errors.Is(err, configmgmt.ErrStoreDisabled):
+ code = http.StatusNotFound
+ case errors.Is(err, configmgmt.ErrConflict):
+ code = http.StatusConflict
+ }
+ return httppkg.NewError(code, err.Error())
+}
+
+// Reload handles GET /api/reload
+func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
+ strictConfigMode := false
+ strictStr := ctx.Query("strictConfig")
+ if strictStr != "" {
+ strictConfigMode, _ = strconv.ParseBool(strictStr)
+ }
+
+ if err := c.manager.ReloadFromFile(strictConfigMode); err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return nil, nil
+}
+
+// Stop handles POST /api/stop
+func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
+ go c.manager.GracefulClose(100 * time.Millisecond)
+ return nil, nil
+}
+
+// Status handles GET /api/status
+func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
+ res := make(model.StatusResp)
+ ps := c.manager.GetProxyStatus()
+ if ps == nil {
+ return res, nil
+ }
+
+ for _, status := range ps {
+ res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))
+ }
+
+ for _, arrs := range res {
+ if len(arrs) <= 1 {
+ continue
+ }
+ slices.SortFunc(arrs, func(a, b model.ProxyStatusResp) int {
+ return cmp.Compare(a.Name, b.Name)
+ })
+ }
+ return res, nil
+}
+
+// GetConfig handles GET /api/config
+func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {
+ content, err := c.manager.ReadConfigFile()
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return content, nil
+}
+
+// PutConfig handles PUT /api/config
+func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
+ body, err := ctx.Body()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err))
+ }
+
+ if len(body) == 0 {
+ return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty")
+ }
+
+ if err := c.manager.WriteConfigFile(body); err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return nil, nil
+}
+
+func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.ProxyStatusResp {
+ psr := model.ProxyStatusResp{
+ Name: status.Name,
+ Type: status.Type,
+ Status: status.Phase,
+ Err: status.Err,
+ }
+ baseCfg := status.Cfg.GetBaseConfig()
+ if baseCfg.LocalPort != 0 {
+ psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
+ }
+ psr.Plugin = baseCfg.Plugin.Type
+
+ if status.Err == "" {
+ psr.RemoteAddr = status.RemoteAddr
+ if slices.Contains([]string{"tcp", "udp"}, status.Type) {
+ psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
+ }
+ }
+
+ if c.manager.IsStoreProxyEnabled(status.Name) {
+ psr.Source = model.SourceStore
+ }
+ return psr
+}
+
+// GetProxyConfig handles GET /api/proxy/{name}/config
+func (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) {
+ name := ctx.Param("name")
+ if name == "" {
+ return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
+ }
+
+ cfg, ok := c.manager.GetProxyConfig(name)
+ if !ok {
+ return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name))
+ }
+
+ payload, err := model.ProxyDefinitionFromConfigurer(cfg)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return payload, nil
+}
+
+// GetVisitorConfig handles GET /api/visitor/{name}/config
+func (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) {
+ name := ctx.Param("name")
+ if name == "" {
+ return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
+ }
+
+ cfg, ok := c.manager.GetVisitorConfig(name)
+ if !ok {
+ return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name))
+ }
+
+ payload, err := model.VisitorDefinitionFromConfigurer(cfg)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return payload, nil
+}
+
+func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
+ proxies, err := c.manager.ListStoreProxies()
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp := model.ProxyListResp{Proxies: make([]model.ProxyDefinition, 0, len(proxies))}
+ for _, p := range proxies {
+ payload, err := model.ProxyDefinitionFromConfigurer(p)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ resp.Proxies = append(resp.Proxies, payload)
+ }
+ slices.SortFunc(resp.Proxies, func(a, b model.ProxyDefinition) int {
+ return cmp.Compare(a.Name, b.Name)
+ })
+ 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, err := c.manager.GetStoreProxy(name)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ payload, err := model.ProxyDefinitionFromConfigurer(p)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+
+ return payload, 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 payload model.ProxyDefinition
+ if err := jsonx.Unmarshal(body, &payload); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
+ }
+
+ if err := payload.Validate("", false); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ cfg, err := payload.ToConfigurer()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ created, err := c.manager.CreateStoreProxy(cfg)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp, err := model.ProxyDefinitionFromConfigurer(created)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return resp, 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 payload model.ProxyDefinition
+ if err := jsonx.Unmarshal(body, &payload); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
+ }
+
+ if err := payload.Validate(name, true); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ cfg, err := payload.ToConfigurer()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ updated, err := c.manager.UpdateStoreProxy(name, cfg)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp, err := model.ProxyDefinitionFromConfigurer(updated)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return resp, 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.manager.DeleteStoreProxy(name); err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return nil, nil
+}
+
+func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) {
+ visitors, err := c.manager.ListStoreVisitors()
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp := model.VisitorListResp{Visitors: make([]model.VisitorDefinition, 0, len(visitors))}
+ for _, v := range visitors {
+ payload, err := model.VisitorDefinitionFromConfigurer(v)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ resp.Visitors = append(resp.Visitors, payload)
+ }
+ slices.SortFunc(resp.Visitors, func(a, b model.VisitorDefinition) int {
+ return cmp.Compare(a.Name, b.Name)
+ })
+ 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, err := c.manager.GetStoreVisitor(name)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ payload, err := model.VisitorDefinitionFromConfigurer(v)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+
+ return payload, 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 payload model.VisitorDefinition
+ if err := jsonx.Unmarshal(body, &payload); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
+ }
+
+ if err := payload.Validate("", false); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ cfg, err := payload.ToConfigurer()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ created, err := c.manager.CreateStoreVisitor(cfg)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp, err := model.VisitorDefinitionFromConfigurer(created)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return resp, 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 payload model.VisitorDefinition
+ if err := jsonx.Unmarshal(body, &payload); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
+ }
+
+ if err := payload.Validate(name, true); err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ cfg, err := payload.ToConfigurer()
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
+ }
+ updated, err := c.manager.UpdateStoreVisitor(name, cfg)
+ if err != nil {
+ return nil, c.toHTTPError(err)
+ }
+
+ resp, err := model.VisitorDefinitionFromConfigurer(updated)
+ if err != nil {
+ return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
+ }
+ return resp, 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.manager.DeleteStoreVisitor(name); err != nil {
+ return nil, c.toHTTPError(err)
+ }
+ return nil, nil
+}
diff --git a/client/http/controller_test.go b/client/http/controller_test.go
new file mode 100644
index 00000000..719fbcd4
--- /dev/null
+++ b/client/http/controller_test.go
@@ -0,0 +1,662 @@
+package http
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gorilla/mux"
+
+ "github.com/fatedier/frp/client/configmgmt"
+ "github.com/fatedier/frp/client/http/model"
+ "github.com/fatedier/frp/client/proxy"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+)
+
+type fakeConfigManager struct {
+ reloadFromFileFn func(strict bool) error
+ readConfigFileFn func() (string, error)
+ writeConfigFileFn func(content []byte) error
+ getProxyStatusFn func() []*proxy.WorkingStatus
+ isStoreProxyEnabledFn func(name string) bool
+ storeEnabledFn func() bool
+ getProxyConfigFn func(name string) (v1.ProxyConfigurer, bool)
+ getVisitorConfigFn func(name string) (v1.VisitorConfigurer, bool)
+
+ listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
+ getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
+ createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
+ updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
+ deleteStoreProxyFn func(name string) error
+ listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error)
+ getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error)
+ createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
+ updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
+ deleteStoreVisitFn func(name string) error
+ gracefulCloseFn func(d time.Duration)
+}
+
+func (m *fakeConfigManager) ReloadFromFile(strict bool) error {
+ if m.reloadFromFileFn != nil {
+ return m.reloadFromFileFn(strict)
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) ReadConfigFile() (string, error) {
+ if m.readConfigFileFn != nil {
+ return m.readConfigFileFn()
+ }
+ return "", nil
+}
+
+func (m *fakeConfigManager) WriteConfigFile(content []byte) error {
+ if m.writeConfigFileFn != nil {
+ return m.writeConfigFileFn(content)
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
+ if m.getProxyStatusFn != nil {
+ return m.getProxyStatusFn()
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) IsStoreProxyEnabled(name string) bool {
+ if m.isStoreProxyEnabledFn != nil {
+ return m.isStoreProxyEnabledFn(name)
+ }
+ return false
+}
+
+func (m *fakeConfigManager) StoreEnabled() bool {
+ if m.storeEnabledFn != nil {
+ return m.storeEnabledFn()
+ }
+ return false
+}
+
+func (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) {
+ if m.getProxyConfigFn != nil {
+ return m.getProxyConfigFn(name)
+ }
+ return nil, false
+}
+
+func (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) {
+ if m.getVisitorConfigFn != nil {
+ return m.getVisitorConfigFn(name)
+ }
+ return nil, false
+}
+
+func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
+ if m.listStoreProxiesFn != nil {
+ return m.listStoreProxiesFn()
+ }
+ return nil, nil
+}
+
+func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {
+ if m.getStoreProxyFn != nil {
+ return m.getStoreProxyFn(name)
+ }
+ return nil, nil
+}
+
+func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ if m.createStoreProxyFn != nil {
+ return m.createStoreProxyFn(cfg)
+ }
+ return cfg, nil
+}
+
+func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ if m.updateStoreProxyFn != nil {
+ return m.updateStoreProxyFn(name, cfg)
+ }
+ return cfg, nil
+}
+
+func (m *fakeConfigManager) DeleteStoreProxy(name string) error {
+ if m.deleteStoreProxyFn != nil {
+ return m.deleteStoreProxyFn(name)
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {
+ if m.listStoreVisitorsFn != nil {
+ return m.listStoreVisitorsFn()
+ }
+ return nil, nil
+}
+
+func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {
+ if m.getStoreVisitorFn != nil {
+ return m.getStoreVisitorFn(name)
+ }
+ return nil, nil
+}
+
+func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ if m.createStoreVisitFn != nil {
+ return m.createStoreVisitFn(cfg)
+ }
+ return cfg, nil
+}
+
+func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ if m.updateStoreVisitFn != nil {
+ return m.updateStoreVisitFn(name, cfg)
+ }
+ return cfg, nil
+}
+
+func (m *fakeConfigManager) DeleteStoreVisitor(name string) error {
+ if m.deleteStoreVisitFn != nil {
+ return m.deleteStoreVisitFn(name)
+ }
+ return nil
+}
+
+func (m *fakeConfigManager) GracefulClose(d time.Duration) {
+ if m.gracefulCloseFn != nil {
+ m.gracefulCloseFn(d)
+ }
+}
+
+func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
+ return &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: name,
+ Type: "tcp",
+ ProxyBackend: v1.ProxyBackend{
+ LocalPort: 10080,
+ },
+ },
+ }
+}
+
+func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
+ status := &proxy.WorkingStatus{
+ Name: "shared-proxy",
+ Type: "tcp",
+ Phase: proxy.ProxyPhaseRunning,
+ RemoteAddr: ":8080",
+ Cfg: newRawTCPProxyConfig("shared-proxy"),
+ }
+
+ controller := &Controller{
+ serverAddr: "127.0.0.1",
+ manager: &fakeConfigManager{
+ isStoreProxyEnabledFn: func(name string) bool {
+ return name == "shared-proxy"
+ },
+ },
+ }
+
+ resp := controller.buildProxyStatusResp(status)
+ if resp.Source != "store" {
+ t.Fatalf("unexpected source: %q", resp.Source)
+ }
+ if resp.RemoteAddr != "127.0.0.1:8080" {
+ t.Fatalf("unexpected remote addr: %q", resp.RemoteAddr)
+ }
+}
+
+func TestReloadErrorMapping(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ expectedCode int
+ }{
+ {name: "invalid arg", err: fmtError(configmgmt.ErrInvalidArgument, "bad cfg"), expectedCode: http.StatusBadRequest},
+ {name: "apply fail", err: fmtError(configmgmt.ErrApplyConfig, "reload failed"), expectedCode: http.StatusInternalServerError},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{reloadFromFileFn: func(bool) error { return tc.err }},
+ }
+ ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/reload", nil))
+ _, err := controller.Reload(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, tc.expectedCode)
+ })
+ }
+}
+
+func TestStoreProxyErrorMapping(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ expectedCode int
+ }{
+ {name: "not found", err: fmtError(configmgmt.ErrNotFound, "not found"), expectedCode: http.StatusNotFound},
+ {name: "conflict", err: fmtError(configmgmt.ErrConflict, "exists"), expectedCode: http.StatusConflict},
+ {name: "internal", err: errors.New("persist failed"), expectedCode: http.StatusInternalServerError},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body))
+ req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ return nil, tc.err
+ },
+ },
+ }
+
+ _, err := controller.UpdateStoreProxy(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, tc.expectedCode)
+ })
+ }
+}
+
+func TestStoreVisitorErrorMapping(t *testing.T) {
+ body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`)
+ req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body))
+ req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ deleteStoreVisitFn: func(string) error {
+ return fmtError(configmgmt.ErrStoreDisabled, "disabled")
+ },
+ },
+ }
+
+ _, err := controller.DeleteStoreVisitor(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusNotFound)
+}
+
+func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) {
+ var gotName string
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ gotName = cfg.GetBaseConfig().Name
+ return cfg, nil
+ },
+ },
+ }
+
+ body := []byte(`{"name":"raw-proxy","type":"tcp","unexpected":"value","tcp":{"localPort":10080,"unknownInBlock":"value"}}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.CreateStoreProxy(ctx)
+ if err != nil {
+ t.Fatalf("create store proxy: %v", err)
+ }
+ if gotName != "raw-proxy" {
+ t.Fatalf("unexpected proxy name: %q", gotName)
+ }
+
+ payload, ok := resp.(model.ProxyDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.Type != "tcp" || payload.TCP == nil {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+}
+
+func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) {
+ var gotName string
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ gotName = cfg.GetBaseConfig().Name
+ return cfg, nil
+ },
+ },
+ }
+
+ body := []byte(`{
+ "name":"raw-visitor","type":"xtcp","unexpected":"value",
+ "xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret","unknownInBlock":"value"}
+ }`)
+ req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.CreateStoreVisitor(ctx)
+ if err != nil {
+ t.Fatalf("create store visitor: %v", err)
+ }
+ if gotName != "raw-visitor" {
+ t.Fatalf("unexpected visitor name: %q", gotName)
+ }
+
+ payload, ok := resp.(model.VisitorDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.Type != "xtcp" || payload.XTCP == nil {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+}
+
+func TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) {
+ var gotPluginType string
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ gotPluginType = cfg.GetBaseConfig().Plugin.Type
+ return cfg, nil
+ },
+ },
+ }
+
+ body := []byte(`{"name":"plugin-proxy","type":"tcp","tcp":{"plugin":{"type":"http2https","localAddr":"127.0.0.1:8080","unknownInPlugin":"value"}}}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.CreateStoreProxy(ctx)
+ if err != nil {
+ t.Fatalf("create store proxy: %v", err)
+ }
+ if gotPluginType != "http2https" {
+ t.Fatalf("unexpected plugin type: %q", gotPluginType)
+ }
+ payload, ok := resp.(model.ProxyDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.TCP == nil {
+ t.Fatalf("unexpected response payload: %#v", payload)
+ }
+ pluginType := payload.TCP.Plugin.Type
+
+ if pluginType != "http2https" {
+ t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
+ }
+}
+
+func TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) {
+ var gotPluginType string
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
+ gotPluginType = cfg.GetBaseConfig().Plugin.Type
+ return cfg, nil
+ },
+ },
+ }
+
+ body := []byte(`{
+ "name":"plugin-visitor","type":"stcp",
+ "stcp":{"serverName":"server","bindPort":10081,"plugin":{"type":"virtual_net","destinationIP":"10.0.0.1","unknownInPlugin":"value"}}
+ }`)
+ req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.CreateStoreVisitor(ctx)
+ if err != nil {
+ t.Fatalf("create store visitor: %v", err)
+ }
+ if gotPluginType != "virtual_net" {
+ t.Fatalf("unexpected plugin type: %q", gotPluginType)
+ }
+ payload, ok := resp.(model.VisitorDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.STCP == nil {
+ t.Fatalf("unexpected response payload: %#v", payload)
+ }
+ pluginType := payload.STCP.Plugin.Type
+
+ if pluginType != "virtual_net" {
+ t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
+ }
+}
+
+func TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) {
+ controller := &Controller{manager: &fakeConfigManager{}}
+ body := []byte(`{"name":"p1","type":"tcp","udp":{"localPort":10080}}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
+ req = mux.SetURLVars(req, map[string]string{"name": "p1"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ _, err := controller.UpdateStoreProxy(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusBadRequest)
+}
+
+func TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) {
+ controller := &Controller{manager: &fakeConfigManager{}}
+ body := []byte(`{"name":"p2","type":"tcp","tcp":{"localPort":10080}}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
+ req = mux.SetURLVars(req, map[string]string{"name": "p1"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ _, err := controller.UpdateStoreProxy(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusBadRequest)
+}
+
+func TestListStoreProxiesReturnsSortedPayload(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ listStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) {
+ b := newRawTCPProxyConfig("b")
+ a := newRawTCPProxyConfig("a")
+ return []v1.ProxyConfigurer{b, a}, nil
+ },
+ },
+ }
+ ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/store/proxies", nil))
+
+ resp, err := controller.ListStoreProxies(ctx)
+ if err != nil {
+ t.Fatalf("list store proxies: %v", err)
+ }
+ out, ok := resp.(model.ProxyListResp)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if len(out.Proxies) != 2 {
+ t.Fatalf("unexpected proxy count: %d", len(out.Proxies))
+ }
+ if out.Proxies[0].Name != "a" || out.Proxies[1].Name != "b" {
+ t.Fatalf("proxies are not sorted by name: %#v", out.Proxies)
+ }
+}
+
+func fmtError(sentinel error, msg string) error {
+ return fmt.Errorf("%w: %s", sentinel, msg)
+}
+
+func assertHTTPCode(t *testing.T, err error, expected int) {
+ t.Helper()
+ var httpErr *httppkg.Error
+ if !errors.As(err, &httpErr) {
+ t.Fatalf("unexpected error type: %T", err)
+ }
+ if httpErr.Code != expected {
+ t.Fatalf("unexpected status code: got %d, want %d", httpErr.Code, expected)
+ }
+}
+
+func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ updateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
+ return cfg, nil
+ },
+ },
+ }
+
+ body := map[string]any{
+ "name": "shared-proxy",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localPort": 10080,
+ "remotePort": 7000,
+ },
+ }
+ data, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal request: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(data))
+ req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.UpdateStoreProxy(ctx)
+ if err != nil {
+ t.Fatalf("update store proxy: %v", err)
+ }
+ payload, ok := resp.(model.ProxyDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.TCP == nil || payload.TCP.RemotePort != 7000 {
+ t.Fatalf("unexpected response payload: %#v", payload)
+ }
+}
+
+func TestGetProxyConfigFromManager(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
+ if name == "ssh" {
+ cfg := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "ssh",
+ Type: "tcp",
+ ProxyBackend: v1.ProxyBackend{
+ LocalPort: 22,
+ },
+ },
+ }
+ return cfg, true
+ }
+ return nil, false
+ },
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/proxy/ssh/config", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "ssh"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.GetProxyConfig(ctx)
+ if err != nil {
+ t.Fatalf("get proxy config: %v", err)
+ }
+ payload, ok := resp.(model.ProxyDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.Name != "ssh" || payload.Type != "tcp" || payload.TCP == nil {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+}
+
+func TestGetProxyConfigNotFound(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) {
+ return nil, false
+ },
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/proxy/missing/config", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "missing"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ _, err := controller.GetProxyConfig(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusNotFound)
+}
+
+func TestGetVisitorConfigFromManager(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
+ if name == "my-stcp" {
+ cfg := &v1.STCPVisitorConfig{
+ VisitorBaseConfig: v1.VisitorBaseConfig{
+ Name: "my-stcp",
+ Type: "stcp",
+ ServerName: "server1",
+ BindPort: 9000,
+ },
+ }
+ return cfg, true
+ }
+ return nil, false
+ },
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/visitor/my-stcp/config", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "my-stcp"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ resp, err := controller.GetVisitorConfig(ctx)
+ if err != nil {
+ t.Fatalf("get visitor config: %v", err)
+ }
+ payload, ok := resp.(model.VisitorDefinition)
+ if !ok {
+ t.Fatalf("unexpected response type: %T", resp)
+ }
+ if payload.Name != "my-stcp" || payload.Type != "stcp" || payload.STCP == nil {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+}
+
+func TestGetVisitorConfigNotFound(t *testing.T) {
+ controller := &Controller{
+ manager: &fakeConfigManager{
+ getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) {
+ return nil, false
+ },
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/visitor/missing/config", nil)
+ req = mux.SetURLVars(req, map[string]string{"name": "missing"})
+ ctx := httppkg.NewContext(httptest.NewRecorder(), req)
+
+ _, err := controller.GetVisitorConfig(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ assertHTTPCode(t, err, http.StatusNotFound)
+}
diff --git a/client/http/model/proxy_definition.go b/client/http/model/proxy_definition.go
new file mode 100644
index 00000000..dae4b4b8
--- /dev/null
+++ b/client/http/model/proxy_definition.go
@@ -0,0 +1,148 @@
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+type ProxyDefinition struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+
+ TCP *v1.TCPProxyConfig `json:"tcp,omitempty"`
+ UDP *v1.UDPProxyConfig `json:"udp,omitempty"`
+ HTTP *v1.HTTPProxyConfig `json:"http,omitempty"`
+ HTTPS *v1.HTTPSProxyConfig `json:"https,omitempty"`
+ TCPMux *v1.TCPMuxProxyConfig `json:"tcpmux,omitempty"`
+ STCP *v1.STCPProxyConfig `json:"stcp,omitempty"`
+ SUDP *v1.SUDPProxyConfig `json:"sudp,omitempty"`
+ XTCP *v1.XTCPProxyConfig `json:"xtcp,omitempty"`
+}
+
+func (p *ProxyDefinition) Validate(pathName string, isUpdate bool) error {
+ if strings.TrimSpace(p.Name) == "" {
+ return fmt.Errorf("proxy name is required")
+ }
+ if !IsProxyType(p.Type) {
+ return fmt.Errorf("invalid proxy type: %s", p.Type)
+ }
+ if isUpdate && pathName != "" && pathName != p.Name {
+ return fmt.Errorf("proxy name in URL must match name in body")
+ }
+
+ _, blockType, blockCount := p.activeBlock()
+ if blockCount != 1 {
+ return fmt.Errorf("exactly one proxy type block is required")
+ }
+ if blockType != p.Type {
+ return fmt.Errorf("proxy type block %q does not match type %q", blockType, p.Type)
+ }
+ return nil
+}
+
+func (p *ProxyDefinition) ToConfigurer() (v1.ProxyConfigurer, error) {
+ block, _, _ := p.activeBlock()
+ if block == nil {
+ return nil, fmt.Errorf("exactly one proxy type block is required")
+ }
+
+ cfg := block
+ cfg.GetBaseConfig().Name = p.Name
+ cfg.GetBaseConfig().Type = p.Type
+ return cfg, nil
+}
+
+func ProxyDefinitionFromConfigurer(cfg v1.ProxyConfigurer) (ProxyDefinition, error) {
+ if cfg == nil {
+ return ProxyDefinition{}, fmt.Errorf("proxy config is nil")
+ }
+
+ base := cfg.GetBaseConfig()
+ payload := ProxyDefinition{
+ Name: base.Name,
+ Type: base.Type,
+ }
+
+ switch c := cfg.(type) {
+ case *v1.TCPProxyConfig:
+ payload.TCP = c
+ case *v1.UDPProxyConfig:
+ payload.UDP = c
+ case *v1.HTTPProxyConfig:
+ payload.HTTP = c
+ case *v1.HTTPSProxyConfig:
+ payload.HTTPS = c
+ case *v1.TCPMuxProxyConfig:
+ payload.TCPMux = c
+ case *v1.STCPProxyConfig:
+ payload.STCP = c
+ case *v1.SUDPProxyConfig:
+ payload.SUDP = c
+ case *v1.XTCPProxyConfig:
+ payload.XTCP = c
+ default:
+ return ProxyDefinition{}, fmt.Errorf("unsupported proxy configurer type %T", cfg)
+ }
+
+ return payload, nil
+}
+
+func (p *ProxyDefinition) activeBlock() (v1.ProxyConfigurer, string, int) {
+ count := 0
+ var block v1.ProxyConfigurer
+ var blockType string
+
+ if p.TCP != nil {
+ count++
+ block = p.TCP
+ blockType = "tcp"
+ }
+ if p.UDP != nil {
+ count++
+ block = p.UDP
+ blockType = "udp"
+ }
+ if p.HTTP != nil {
+ count++
+ block = p.HTTP
+ blockType = "http"
+ }
+ if p.HTTPS != nil {
+ count++
+ block = p.HTTPS
+ blockType = "https"
+ }
+ if p.TCPMux != nil {
+ count++
+ block = p.TCPMux
+ blockType = "tcpmux"
+ }
+ if p.STCP != nil {
+ count++
+ block = p.STCP
+ blockType = "stcp"
+ }
+ if p.SUDP != nil {
+ count++
+ block = p.SUDP
+ blockType = "sudp"
+ }
+ if p.XTCP != nil {
+ count++
+ block = p.XTCP
+ blockType = "xtcp"
+ }
+
+ return block, blockType, count
+}
+
+func IsProxyType(typ string) bool {
+ switch typ {
+ case "tcp", "udp", "http", "https", "tcpmux", "stcp", "sudp", "xtcp":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/client/api/types.go b/client/http/model/types.go
similarity index 72%
rename from client/api/types.go
rename to client/http/model/types.go
index d7c930d0..63704850 100644
--- a/client/api/types.go
+++ b/client/http/model/types.go
@@ -12,7 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package api
+package model
+
+const SourceStore = "store"
// StatusResp is the response for GET /api/status
type StatusResp map[string][]ProxyStatusResp
@@ -26,4 +28,15 @@ 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"
+}
+
+// ProxyListResp is the response for GET /api/store/proxies
+type ProxyListResp struct {
+ Proxies []ProxyDefinition `json:"proxies"`
+}
+
+// VisitorListResp is the response for GET /api/store/visitors
+type VisitorListResp struct {
+ Visitors []VisitorDefinition `json:"visitors"`
}
diff --git a/client/http/model/visitor_definition.go b/client/http/model/visitor_definition.go
new file mode 100644
index 00000000..a108982d
--- /dev/null
+++ b/client/http/model/visitor_definition.go
@@ -0,0 +1,107 @@
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+type VisitorDefinition struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+
+ STCP *v1.STCPVisitorConfig `json:"stcp,omitempty"`
+ SUDP *v1.SUDPVisitorConfig `json:"sudp,omitempty"`
+ XTCP *v1.XTCPVisitorConfig `json:"xtcp,omitempty"`
+}
+
+func (p *VisitorDefinition) Validate(pathName string, isUpdate bool) error {
+ if strings.TrimSpace(p.Name) == "" {
+ return fmt.Errorf("visitor name is required")
+ }
+ if !IsVisitorType(p.Type) {
+ return fmt.Errorf("invalid visitor type: %s", p.Type)
+ }
+ if isUpdate && pathName != "" && pathName != p.Name {
+ return fmt.Errorf("visitor name in URL must match name in body")
+ }
+
+ _, blockType, blockCount := p.activeBlock()
+ if blockCount != 1 {
+ return fmt.Errorf("exactly one visitor type block is required")
+ }
+ if blockType != p.Type {
+ return fmt.Errorf("visitor type block %q does not match type %q", blockType, p.Type)
+ }
+ return nil
+}
+
+func (p *VisitorDefinition) ToConfigurer() (v1.VisitorConfigurer, error) {
+ block, _, _ := p.activeBlock()
+ if block == nil {
+ return nil, fmt.Errorf("exactly one visitor type block is required")
+ }
+
+ cfg := block
+ cfg.GetBaseConfig().Name = p.Name
+ cfg.GetBaseConfig().Type = p.Type
+ return cfg, nil
+}
+
+func VisitorDefinitionFromConfigurer(cfg v1.VisitorConfigurer) (VisitorDefinition, error) {
+ if cfg == nil {
+ return VisitorDefinition{}, fmt.Errorf("visitor config is nil")
+ }
+
+ base := cfg.GetBaseConfig()
+ payload := VisitorDefinition{
+ Name: base.Name,
+ Type: base.Type,
+ }
+
+ switch c := cfg.(type) {
+ case *v1.STCPVisitorConfig:
+ payload.STCP = c
+ case *v1.SUDPVisitorConfig:
+ payload.SUDP = c
+ case *v1.XTCPVisitorConfig:
+ payload.XTCP = c
+ default:
+ return VisitorDefinition{}, fmt.Errorf("unsupported visitor configurer type %T", cfg)
+ }
+
+ return payload, nil
+}
+
+func (p *VisitorDefinition) activeBlock() (v1.VisitorConfigurer, string, int) {
+ count := 0
+ var block v1.VisitorConfigurer
+ var blockType string
+
+ if p.STCP != nil {
+ count++
+ block = p.STCP
+ blockType = "stcp"
+ }
+ if p.SUDP != nil {
+ count++
+ block = p.SUDP
+ blockType = "sudp"
+ }
+ if p.XTCP != nil {
+ count++
+ block = p.XTCP
+ blockType = "xtcp"
+ }
+ return block, blockType, count
+}
+
+func IsVisitorType(typ string) bool {
+ switch typ {
+ case "stcp", "sudp", "xtcp":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/client/proxy/proxy.go b/client/proxy/proxy.go
index 8faff38d..84ff49a1 100644
--- a/client/proxy/proxy.go
+++ b/client/proxy/proxy.go
@@ -16,6 +16,7 @@ package proxy
import (
"context"
+ "fmt"
"io"
"net"
"reflect"
@@ -122,6 +123,33 @@ func (pxy *BaseProxy) Close() {
}
}
+// wrapWorkConn applies rate limiting, encryption, and compression
+// to a work connection based on the proxy's transport configuration.
+// The returned recycle function should be called when the stream is no longer in use
+// to return compression resources to the pool. It is safe to not call recycle,
+// in which case resources will be garbage collected normally.
+func (pxy *BaseProxy) wrapWorkConn(conn net.Conn, encKey []byte) (io.ReadWriteCloser, func(), error) {
+ var rwc io.ReadWriteCloser = conn
+ if pxy.limiter != nil {
+ rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
+ return conn.Close()
+ })
+ }
+ if pxy.baseCfg.Transport.UseEncryption {
+ var err error
+ rwc, err = libio.WithEncryption(rwc, encKey)
+ if err != nil {
+ conn.Close()
+ return nil, nil, fmt.Errorf("create encryption stream error: %w", err)
+ }
+ }
+ var recycleFn func()
+ if pxy.baseCfg.Transport.UseCompression {
+ rwc, recycleFn = libio.WithCompressionFromPool(rwc)
+ }
+ return rwc, recycleFn, nil
+}
+
func (pxy *BaseProxy) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) {
pxy.inWorkConnCallback = cb
}
@@ -139,30 +167,14 @@ func (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) {
func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWorkConn, encKey []byte) {
xl := pxy.xl
baseCfg := pxy.baseCfg
- var (
- remote io.ReadWriteCloser
- err error
- )
- remote = workConn
- if pxy.limiter != nil {
- remote = libio.WrapReadWriteCloser(limit.NewReader(workConn, pxy.limiter), limit.NewWriter(workConn, pxy.limiter), func() error {
- return workConn.Close()
- })
- }
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
- if baseCfg.Transport.UseEncryption {
- remote, err = libio.WithEncryption(remote, encKey)
- if err != nil {
- workConn.Close()
- xl.Errorf("create encryption stream error: %v", err)
- return
- }
- }
- var compressionResourceRecycleFn func()
- if baseCfg.Transport.UseCompression {
- remote, compressionResourceRecycleFn = libio.WithCompressionFromPool(remote)
+
+ remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)
+ if err != nil {
+ xl.Errorf("wrap work connection: %v", err)
+ return
}
// check if we need to send proxy protocol info
@@ -178,7 +190,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
}
if baseCfg.Transport.ProxyProtocolVersion != "" && m.SrcAddr != "" && m.SrcPort != 0 {
- // Use the common proxy protocol builder function
header := netpkg.BuildProxyProtocolHeaderStruct(connInfo.SrcAddr, connInfo.DstAddr, baseCfg.Transport.ProxyProtocolVersion)
connInfo.ProxyProtocolHeader = header
}
@@ -187,12 +198,18 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
if pxy.proxyPlugin != nil {
// if plugin is set, let plugin handle connection first
+ // Don't recycle compression resources here because plugins may
+ // retain the connection after Handle returns.
xl.Debugf("handle by plugin: %s", pxy.proxyPlugin.Name())
pxy.proxyPlugin.Handle(pxy.ctx, &connInfo)
xl.Debugf("handle by plugin finished")
return
}
+ if recycleFn != nil {
+ defer recycleFn()
+ }
+
localConn, err := libnet.Dial(
net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort)),
libnet.WithTimeout(10*time.Second),
@@ -209,6 +226,7 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
if connInfo.ProxyProtocolHeader != nil {
if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
workConn.Close()
+ localConn.Close()
xl.Errorf("write proxy protocol header to local conn error: %v", err)
return
}
@@ -219,7 +237,4 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
if len(errs) > 0 {
xl.Tracef("join connections errors: %v", errs)
}
- if compressionResourceRecycleFn != nil {
- compressionResourceRecycleFn()
- }
}
diff --git a/client/proxy/proxy_manager.go b/client/proxy/proxy_manager.go
index 4615e9a2..42f9f589 100644
--- a/client/proxy/proxy_manager.go
+++ b/client/proxy/proxy_manager.go
@@ -118,9 +118,9 @@ func (pm *Manager) HandleEvent(payload any) error {
}
func (pm *Manager) GetAllProxyStatus() []*WorkingStatus {
- ps := make([]*WorkingStatus, 0)
pm.mu.RLock()
defer pm.mu.RUnlock()
+ ps := make([]*WorkingStatus, 0, len(pm.proxies))
for _, pxy := range pm.proxies {
ps = append(ps, pxy.GetStatus())
}
diff --git a/client/proxy/proxy_wrapper.go b/client/proxy/proxy_wrapper.go
index 4698320a..718c02e6 100644
--- a/client/proxy/proxy_wrapper.go
+++ b/client/proxy/proxy_wrapper.go
@@ -29,6 +29,7 @@ import (
"github.com/fatedier/frp/client/health"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/transport"
"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: naming.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,
diff --git a/client/proxy/sudp.go b/client/proxy/sudp.go
index 3a7af19c..21e34a41 100644
--- a/client/proxy/sudp.go
+++ b/client/proxy/sudp.go
@@ -17,7 +17,6 @@
package proxy
import (
- "io"
"net"
"reflect"
"strconv"
@@ -25,17 +24,15 @@ import (
"time"
"github.com/fatedier/golib/errors"
- libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/udp"
- "github.com/fatedier/frp/pkg/util/limit"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.SUDPProxyConfig{}), NewSUDPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy)
}
type SUDPProxy struct {
@@ -83,27 +80,13 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
xl := pxy.xl
xl.Infof("incoming a new work connection for sudp proxy, %s", conn.RemoteAddr().String())
- var rwc io.ReadWriteCloser = conn
- var err error
- if pxy.limiter != nil {
- rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
- return conn.Close()
- })
+ remote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey)
+ if err != nil {
+ xl.Errorf("wrap work connection: %v", err)
+ return
}
- if pxy.cfg.Transport.UseEncryption {
- rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
- if err != nil {
- conn.Close()
- xl.Errorf("create encryption stream error: %v", err)
- return
- }
- }
- if pxy.cfg.Transport.UseCompression {
- rwc = libio.WithCompression(rwc)
- }
- conn = netpkg.WrapReadWriteCloserToConn(rwc, conn)
- workConn := conn
+ workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
readCh := make(chan *msg.UDPPacket, 1024)
sendCh := make(chan msg.Message, 1024)
isClose := false
diff --git a/client/proxy/udp.go b/client/proxy/udp.go
index 1fca9904..8547ef72 100644
--- a/client/proxy/udp.go
+++ b/client/proxy/udp.go
@@ -17,24 +17,21 @@
package proxy
import (
- "io"
"net"
"reflect"
"strconv"
"time"
"github.com/fatedier/golib/errors"
- libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/udp"
- "github.com/fatedier/frp/pkg/util/limit"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.UDPProxyConfig{}), NewUDPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy)
}
type UDPProxy struct {
@@ -94,28 +91,14 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
// close resources related with old workConn
pxy.Close()
- var rwc io.ReadWriteCloser = conn
- var err error
- if pxy.limiter != nil {
- rwc = libio.WrapReadWriteCloser(limit.NewReader(conn, pxy.limiter), limit.NewWriter(conn, pxy.limiter), func() error {
- return conn.Close()
- })
+ remote, _, err := pxy.wrapWorkConn(conn, pxy.encryptionKey)
+ if err != nil {
+ xl.Errorf("wrap work connection: %v", err)
+ return
}
- if pxy.cfg.Transport.UseEncryption {
- rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
- if err != nil {
- conn.Close()
- xl.Errorf("create encryption stream error: %v", err)
- return
- }
- }
- if pxy.cfg.Transport.UseCompression {
- rwc = libio.WithCompression(rwc)
- }
- conn = netpkg.WrapReadWriteCloserToConn(rwc, conn)
pxy.mu.Lock()
- pxy.workConn = conn
+ pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false
@@ -129,7 +112,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
return
}
if errRet := errors.PanicToError(func() {
- xl.Tracef("get udp package from workConn: %s", udpMsg.Content)
+ xl.Tracef("get udp package from workConn, len: %d", len(udpMsg.Content))
readCh <- &udpMsg
}); errRet != nil {
xl.Infof("reader goroutine for udp work connection closed: %v", errRet)
@@ -145,7 +128,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
for rawMsg := range sendCh {
switch m := rawMsg.(type) {
case *msg.UDPPacket:
- xl.Tracef("send udp package to workConn: %s", m.Content)
+ xl.Tracef("send udp package to workConn, len: %d", len(m.Content))
case *msg.Ping:
xl.Tracef("send ping message to udp workConn")
}
diff --git a/client/proxy/xtcp.go b/client/proxy/xtcp.go
index 6e1deac3..41dc5229 100644
--- a/client/proxy/xtcp.go
+++ b/client/proxy/xtcp.go
@@ -27,13 +27,14 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/nathole"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.XTCPProxyConfig{}), NewXTCPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy)
}
type XTCPProxy struct {
@@ -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: naming.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name),
Sid: natHoleSidMsg.Sid,
MappedAddrs: prepareResult.Addrs,
AssistedAddrs: prepareResult.AssistedAddrs,
diff --git a/client/service.go b/client/service.go
index 8d639698..5c51fd67 100644
--- a/client/service.go
+++ b/client/service.go
@@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"net"
+ "net/http"
"os"
"runtime"
"sync"
@@ -29,6 +30,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 +64,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 +124,23 @@ type Service struct {
vnetController *vnet.Controller
- cfgMu sync.RWMutex
- common *v1.ClientCommonConfig
- proxyCfgs []v1.ProxyConfigurer
- visitorCfgs []v1.VisitorConfigurer
- clientSpec *msg.ClientSpec
+ cfgMu sync.RWMutex
+ // reloadMu serializes reload transactions to keep reloadCommon and applied
+ // config in sync across concurrent API operations.
+ reloadMu sync.Mutex
+ 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
@@ -146,6 +163,28 @@ func NewService(options ServiceOptions) (*Service, error) {
return nil, err
}
+ authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
+ if err != nil {
+ 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)
+
+ // Create the web server after all fallible steps so its listener is not
+ // leaked when an earlier error causes NewService to return.
var webServer *httppkg.Server
if options.Common.WebServer.Port > 0 {
ws, err := httppkg.NewServer(options.Common.WebServer)
@@ -155,24 +194,24 @@ func NewService(options ServiceOptions) (*Service, error) {
webServer = ws
}
- authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
- if err != nil {
- return nil, err
- }
-
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)
}
@@ -193,22 +232,25 @@ func (svr *Service) Run(ctx context.Context) error {
}
if svr.vnetController != nil {
+ vnetController := svr.vnetController
if err := svr.vnetController.Init(); err != nil {
log.Errorf("init virtual network controller error: %v", err)
+ svr.stop()
return err
}
go func() {
log.Infof("virtual network controller start...")
- if err := svr.vnetController.Run(); err != nil {
+ if err := vnetController.Run(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Warnf("virtual network controller exit with error: %v", err)
}
}()
}
if svr.webServer != nil {
+ webServer := svr.webServer
go func() {
- log.Infof("admin server listen on %s", svr.webServer.Address())
- if err := svr.webServer.Run(); err != nil {
+ log.Infof("admin server listen on %s", webServer.Address())
+ if err := webServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Warnf("admin server exit with error: %v", err)
}
}()
@@ -219,6 +261,7 @@ func (svr *Service) Run(ctx context.Context) error {
if svr.ctl == nil {
cancelCause := cancelErr{}
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
+ svr.stop()
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
}
@@ -403,6 +446,35 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
return nil
}
+func (svr *Service) UpdateConfigSource(
+ common *v1.ClientCommonConfig,
+ proxyCfgs []v1.ProxyConfigurer,
+ visitorCfgs []v1.VisitorConfigurer,
+) error {
+ svr.reloadMu.Lock()
+ defer svr.reloadMu.Unlock()
+
+ cfgSource := svr.configSource
+ if cfgSource == nil {
+ return fmt.Errorf("config source is not available")
+ }
+
+ if err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
+ return err
+ }
+
+ // Non-atomic update semantics: source has been updated at this point.
+ // Even if reload fails below, keep this common config for subsequent reloads.
+ svr.cfgMu.Lock()
+ svr.reloadCommon = common
+ svr.cfgMu.Unlock()
+
+ if err := svr.reloadConfigFromSourcesLocked(); err != nil {
+ return err
+ }
+ return nil
+}
+
func (svr *Service) Close() {
svr.GracefulClose(time.Duration(0))
}
@@ -413,6 +485,15 @@ func (svr *Service) GracefulClose(d time.Duration) {
}
func (svr *Service) stop() {
+ // Coordinate shutdown with reload/update paths that read source pointers.
+ svr.reloadMu.Lock()
+ if svr.aggregator != nil {
+ svr.aggregator = nil
+ }
+ svr.configSource = nil
+ svr.storeSource = nil
+ svr.reloadMu.Unlock()
+
svr.ctlMu.Lock()
defer svr.ctlMu.Unlock()
if svr.ctl != nil {
@@ -423,6 +504,10 @@ func (svr *Service) stop() {
svr.webServer.Close()
svr.webServer = nil
}
+ if svr.vnetController != nil {
+ _ = svr.vnetController.Stop()
+ svr.vnetController = nil
+ }
}
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
@@ -436,6 +521,17 @@ func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
return ctl.pm.GetProxyStatus(name)
}
+func (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
+ svr.ctlMu.RLock()
+ ctl := svr.ctl
+ svr.ctlMu.RUnlock()
+
+ if ctl == nil {
+ return nil, false
+ }
+ return ctl.vm.GetVisitorCfg(name)
+}
+
func (svr *Service) StatusExporter() StatusExporter {
return &statusExporterImpl{
getProxyStatusFunc: svr.getProxyStatus,
@@ -453,3 +549,35 @@ type statusExporterImpl struct {
func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) {
return s.getProxyStatusFunc(name)
}
+
+func (svr *Service) reloadConfigFromSources() error {
+ svr.reloadMu.Lock()
+ defer svr.reloadMu.Unlock()
+ return svr.reloadConfigFromSourcesLocked()
+}
+
+func (svr *Service) reloadConfigFromSourcesLocked() error {
+ aggregator := svr.aggregator
+ if aggregator == nil {
+ return errors.New("config aggregator is not initialized")
+ }
+
+ svr.cfgMu.RLock()
+ reloadCommon := svr.reloadCommon
+ svr.cfgMu.RUnlock()
+
+ proxies, visitors, err := 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
+}
diff --git a/client/service_test.go b/client/service_test.go
new file mode 100644
index 00000000..29f141a1
--- /dev/null
+++ b/client/service_test.go
@@ -0,0 +1,246 @@
+package client
+
+import (
+ "context"
+ "errors"
+ "net"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/samber/lo"
+
+ "github.com/fatedier/frp/pkg/config/source"
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+type failingConnector struct {
+ err error
+}
+
+func (c *failingConnector) Open() error {
+ return c.err
+}
+
+func (c *failingConnector) Connect() (net.Conn, error) {
+ return nil, c.err
+}
+
+func (c *failingConnector) Close() error {
+ return nil
+}
+
+func getFreeTCPPort(t *testing.T) int {
+ t.Helper()
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen on ephemeral port: %v", err)
+ }
+ defer ln.Close()
+
+ return ln.Addr().(*net.TCPAddr).Port
+}
+
+func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
+ port := getFreeTCPPort(t)
+ agg := source.NewAggregator(source.NewConfigSource())
+
+ svr, err := NewService(ServiceOptions{
+ Common: &v1.ClientCommonConfig{
+ LoginFailExit: lo.ToPtr(true),
+ WebServer: v1.WebServerConfig{
+ Addr: "127.0.0.1",
+ Port: port,
+ },
+ },
+ ConfigSourceAggregator: agg,
+ ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
+ return &failingConnector{err: errors.New("login boom")}
+ },
+ })
+ if err != nil {
+ t.Fatalf("new service: %v", err)
+ }
+
+ err = svr.Run(context.Background())
+ if err == nil {
+ t.Fatal("expected run error, got nil")
+ }
+ if !strings.Contains(err.Error(), "login boom") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if svr.webServer != nil {
+ t.Fatal("expected web server to be cleaned up after initial login failure")
+ }
+
+ ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
+ if err != nil {
+ t.Fatalf("expected admin port to be released: %v", err)
+ }
+ _ = ln.Close()
+}
+
+func TestNewServiceDoesNotLeakAdminListenerOnAuthBuildFailure(t *testing.T) {
+ port := getFreeTCPPort(t)
+ agg := source.NewAggregator(source.NewConfigSource())
+
+ _, err := NewService(ServiceOptions{
+ Common: &v1.ClientCommonConfig{
+ Auth: v1.AuthClientConfig{
+ Method: v1.AuthMethodOIDC,
+ OIDC: v1.AuthOIDCClientConfig{
+ TokenEndpointURL: "://bad",
+ },
+ },
+ WebServer: v1.WebServerConfig{
+ Addr: "127.0.0.1",
+ Port: port,
+ },
+ },
+ ConfigSourceAggregator: agg,
+ })
+ if err == nil {
+ t.Fatal("expected new service error, got nil")
+ }
+ if !strings.Contains(err.Error(), "auth.oidc.tokenEndpointURL") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
+ if err != nil {
+ t.Fatalf("expected admin port to remain free: %v", err)
+ }
+ _ = ln.Close()
+}
+
+func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
+ prevCommon := &v1.ClientCommonConfig{User: "old-user"}
+ newCommon := &v1.ClientCommonConfig{User: "new-user"}
+
+ svr := &Service{
+ configSource: source.NewConfigSource(),
+ reloadCommon: prevCommon,
+ }
+
+ invalidProxy := &v1.TCPProxyConfig{}
+ err := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{invalidProxy}, nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "proxy name cannot be empty") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if svr.reloadCommon != prevCommon {
+ t.Fatalf("reloadCommon should roll back on ReplaceAll failure")
+ }
+}
+
+func TestUpdateConfigSourceKeepsReloadCommonOnReloadFailure(t *testing.T) {
+ prevCommon := &v1.ClientCommonConfig{User: "old-user"}
+ newCommon := &v1.ClientCommonConfig{User: "new-user"}
+
+ svr := &Service{
+ // Keep configSource valid so ReplaceAll succeeds first.
+ configSource: source.NewConfigSource(),
+ reloadCommon: prevCommon,
+ // Keep aggregator nil to force reload failure.
+ aggregator: nil,
+ }
+
+ validProxy := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "p1",
+ Type: "tcp",
+ },
+ }
+ err := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{validProxy}, nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "config aggregator is not initialized") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if svr.reloadCommon != newCommon {
+ t.Fatalf("reloadCommon should keep new value on reload failure")
+ }
+}
+
+func TestReloadConfigFromSourcesDoesNotMutateStoreConfigs(t *testing.T) {
+ storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
+ Path: filepath.Join(t.TempDir(), "store.json"),
+ })
+ if err != nil {
+ t.Fatalf("new store source: %v", err)
+ }
+
+ proxyCfg := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "store-proxy",
+ Type: "tcp",
+ },
+ }
+ visitorCfg := &v1.STCPVisitorConfig{
+ VisitorBaseConfig: v1.VisitorBaseConfig{
+ Name: "store-visitor",
+ Type: "stcp",
+ },
+ }
+ if err := storeSource.AddProxy(proxyCfg); err != nil {
+ t.Fatalf("add proxy to store: %v", err)
+ }
+ if err := storeSource.AddVisitor(visitorCfg); err != nil {
+ t.Fatalf("add visitor to store: %v", err)
+ }
+
+ agg := source.NewAggregator(source.NewConfigSource())
+ agg.SetStoreSource(storeSource)
+ svr := &Service{
+ aggregator: agg,
+ configSource: agg.ConfigSource(),
+ storeSource: storeSource,
+ reloadCommon: &v1.ClientCommonConfig{},
+ }
+
+ if err := svr.reloadConfigFromSources(); err != nil {
+ t.Fatalf("reload config from sources: %v", err)
+ }
+
+ gotProxy := storeSource.GetProxy("store-proxy")
+ if gotProxy == nil {
+ t.Fatalf("proxy not found in store")
+ }
+ if gotProxy.GetBaseConfig().LocalIP != "" {
+ t.Fatalf("store proxy localIP should stay empty, got %q", gotProxy.GetBaseConfig().LocalIP)
+ }
+
+ gotVisitor := storeSource.GetVisitor("store-visitor")
+ if gotVisitor == nil {
+ t.Fatalf("visitor not found in store")
+ }
+ if gotVisitor.GetBaseConfig().BindAddr != "" {
+ t.Fatalf("store visitor bindAddr should stay empty, got %q", gotVisitor.GetBaseConfig().BindAddr)
+ }
+
+ svr.cfgMu.RLock()
+ defer svr.cfgMu.RUnlock()
+
+ if len(svr.proxyCfgs) != 1 {
+ t.Fatalf("expected 1 runtime proxy, got %d", len(svr.proxyCfgs))
+ }
+ if svr.proxyCfgs[0].GetBaseConfig().LocalIP != "127.0.0.1" {
+ t.Fatalf("runtime proxy localIP should be defaulted, got %q", svr.proxyCfgs[0].GetBaseConfig().LocalIP)
+ }
+
+ if len(svr.visitorCfgs) != 1 {
+ t.Fatalf("expected 1 runtime visitor, got %d", len(svr.visitorCfgs))
+ }
+ if svr.visitorCfgs[0].GetBaseConfig().BindAddr != "127.0.0.1" {
+ t.Fatalf("runtime visitor bindAddr should be defaulted, got %q", svr.visitorCfgs[0].GetBaseConfig().BindAddr)
+ }
+}
diff --git a/client/visitor/stcp.go b/client/visitor/stcp.go
index 31f6f174..03ec51fe 100644
--- a/client/visitor/stcp.go
+++ b/client/visitor/stcp.go
@@ -15,17 +15,12 @@
package visitor
import (
- "fmt"
- "io"
"net"
"strconv"
- "time"
libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
- "github.com/fatedier/frp/pkg/msg"
- "github.com/fatedier/frp/pkg/util/util"
"github.com/fatedier/frp/pkg/util/xlog"
)
@@ -41,10 +36,10 @@ func (sv *STCPVisitor) Run() (err error) {
if err != nil {
return
}
- go sv.worker()
+ go sv.acceptLoop(sv.l, "stcp local", sv.handleConn)
}
- go sv.internalConnWorker()
+ go sv.acceptLoop(sv.internalLn, "stcp internal", sv.handleConn)
if sv.plugin != nil {
sv.plugin.Start()
@@ -56,35 +51,10 @@ func (sv *STCPVisitor) Close() {
sv.BaseVisitor.Close()
}
-func (sv *STCPVisitor) worker() {
- xl := xlog.FromContextSafe(sv.ctx)
- for {
- conn, err := sv.l.Accept()
- if err != nil {
- xl.Warnf("stcp local listener closed")
- return
- }
- go sv.handleConn(conn)
- }
-}
-
-func (sv *STCPVisitor) internalConnWorker() {
- xl := xlog.FromContextSafe(sv.ctx)
- for {
- conn, err := sv.internalLn.Accept()
- if err != nil {
- xl.Warnf("stcp internal listener closed")
- return
- }
- go sv.handleConn(conn)
- }
-}
-
func (sv *STCPVisitor) handleConn(userConn net.Conn) {
xl := xlog.FromContextSafe(sv.ctx)
var tunnelErr error
defer func() {
- // If there was an error and connection supports CloseWithError, use it
if tunnelErr != nil {
if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok {
_ = eConn.CloseWithError(tunnelErr)
@@ -95,61 +65,21 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
}()
xl.Debugf("get a new stcp user connection")
- visitorConn, err := sv.helper.ConnectServer()
+ visitorConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig())
if err != nil {
+ xl.Warnf("dialRawVisitorConn error: %v", err)
tunnelErr = err
return
}
defer visitorConn.Close()
- now := time.Now().Unix()
- newVisitorConnMsg := &msg.NewVisitorConn{
- RunID: sv.helper.RunID(),
- ProxyName: sv.cfg.ServerName,
- SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
- Timestamp: now,
- UseEncryption: sv.cfg.Transport.UseEncryption,
- UseCompression: sv.cfg.Transport.UseCompression,
- }
- err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
+ remote, recycleFn, err := wrapVisitorConn(visitorConn, sv.cfg.GetBaseConfig())
if err != nil {
- xl.Warnf("send newVisitorConnMsg to server error: %v", err)
+ xl.Warnf("wrapVisitorConn error: %v", err)
tunnelErr = err
return
}
-
- var newVisitorConnRespMsg msg.NewVisitorConnResp
- _ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
- err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
- if err != nil {
- xl.Warnf("get newVisitorConnRespMsg error: %v", err)
- tunnelErr = err
- return
- }
- _ = visitorConn.SetReadDeadline(time.Time{})
-
- if newVisitorConnRespMsg.Error != "" {
- xl.Warnf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
- tunnelErr = fmt.Errorf("%s", newVisitorConnRespMsg.Error)
- return
- }
-
- var remote io.ReadWriteCloser
- remote = visitorConn
- if sv.cfg.Transport.UseEncryption {
- remote, err = libio.WithEncryption(remote, []byte(sv.cfg.SecretKey))
- if err != nil {
- xl.Errorf("create encryption stream error: %v", err)
- tunnelErr = err
- return
- }
- }
-
- if sv.cfg.Transport.UseCompression {
- var recycleFn func()
- remote, recycleFn = libio.WithCompressionFromPool(remote)
- defer recycleFn()
- }
+ defer recycleFn()
libio.Join(userConn, remote)
}
diff --git a/client/visitor/sudp.go b/client/visitor/sudp.go
index 284aee10..6014161c 100644
--- a/client/visitor/sudp.go
+++ b/client/visitor/sudp.go
@@ -16,20 +16,17 @@ package visitor
import (
"fmt"
- "io"
"net"
"strconv"
"sync"
"time"
"github.com/fatedier/golib/errors"
- libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/udp"
netpkg "github.com/fatedier/frp/pkg/util/net"
- "github.com/fatedier/frp/pkg/util/util"
"github.com/fatedier/frp/pkg/util/xlog"
)
@@ -75,6 +72,7 @@ func (sv *SUDPVisitor) dispatcher() {
var (
visitorConn net.Conn
+ recycleFn func()
err error
firstPacket *msg.UDPPacket
@@ -92,14 +90,17 @@ func (sv *SUDPVisitor) dispatcher() {
return
}
- visitorConn, err = sv.getNewVisitorConn()
+ visitorConn, recycleFn, err = sv.getNewVisitorConn()
if err != nil {
xl.Warnf("newVisitorConn to frps error: %v, try to reconnect", err)
continue
}
// visitorConn always be closed when worker done.
- sv.worker(visitorConn, firstPacket)
+ func() {
+ defer recycleFn()
+ sv.worker(visitorConn, firstPacket)
+ }()
select {
case <-sv.checkCloseCh:
@@ -146,7 +147,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
case *msg.UDPPacket:
if errRet := errors.PanicToError(func() {
sv.readCh <- m
- xl.Tracef("frpc visitor get udp packet from workConn: %s", m.Content)
+ xl.Tracef("frpc visitor get udp packet from workConn, len: %d", len(m.Content))
}); errRet != nil {
xl.Infof("reader goroutine for udp work connection closed")
return
@@ -168,7 +169,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
- xl.Tracef("send udp package to workConn: %s", firstPacket.Content)
+ xl.Tracef("send udp package to workConn, len: %d", len(firstPacket.Content))
}
for {
@@ -183,7 +184,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
- xl.Tracef("send udp package to workConn: %s", udpMsg.Content)
+ xl.Tracef("send udp package to workConn, len: %d", len(udpMsg.Content))
case <-closeCh:
return
}
@@ -197,52 +198,17 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl.Infof("sudp worker is closed")
}
-func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) {
- xl := xlog.FromContextSafe(sv.ctx)
- visitorConn, err := sv.helper.ConnectServer()
+func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, func(), error) {
+ rawConn, err := sv.dialRawVisitorConn(sv.cfg.GetBaseConfig())
if err != nil {
- return nil, fmt.Errorf("frpc connect frps error: %v", err)
+ return nil, func() {}, err
}
-
- now := time.Now().Unix()
- newVisitorConnMsg := &msg.NewVisitorConn{
- RunID: sv.helper.RunID(),
- ProxyName: sv.cfg.ServerName,
- SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
- Timestamp: now,
- UseEncryption: sv.cfg.Transport.UseEncryption,
- UseCompression: sv.cfg.Transport.UseCompression,
- }
- err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
+ rwc, recycleFn, err := wrapVisitorConn(rawConn, sv.cfg.GetBaseConfig())
if err != nil {
- return nil, fmt.Errorf("frpc send newVisitorConnMsg to frps error: %v", err)
+ rawConn.Close()
+ return nil, func() {}, err
}
-
- var newVisitorConnRespMsg msg.NewVisitorConnResp
- _ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
- err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
- if err != nil {
- return nil, fmt.Errorf("frpc read newVisitorConnRespMsg error: %v", err)
- }
- _ = visitorConn.SetReadDeadline(time.Time{})
-
- if newVisitorConnRespMsg.Error != "" {
- return nil, fmt.Errorf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
- }
-
- var remote io.ReadWriteCloser
- remote = visitorConn
- if sv.cfg.Transport.UseEncryption {
- remote, err = libio.WithEncryption(remote, []byte(sv.cfg.SecretKey))
- if err != nil {
- xl.Errorf("create encryption stream error: %v", err)
- return nil, err
- }
- }
- if sv.cfg.Transport.UseCompression {
- remote = libio.WithCompression(remote)
- }
- return netpkg.WrapReadWriteCloserToConn(remote, visitorConn), nil
+ return netpkg.WrapReadWriteCloserToConn(rwc, rawConn), recycleFn, nil
}
func (sv *SUDPVisitor) Close() {
diff --git a/client/visitor/visitor.go b/client/visitor/visitor.go
index 87e4f29f..14a7aa37 100644
--- a/client/visitor/visitor.go
+++ b/client/visitor/visitor.go
@@ -16,13 +16,21 @@ package visitor
import (
"context"
+ "fmt"
+ "io"
"net"
"sync"
+ "time"
+
+ libio "github.com/fatedier/golib/io"
v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
plugin "github.com/fatedier/frp/pkg/plugin/visitor"
"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/xlog"
"github.com/fatedier/frp/pkg/vnet"
)
@@ -119,6 +127,18 @@ func (v *BaseVisitor) AcceptConn(conn net.Conn) error {
return v.internalLn.PutConn(conn)
}
+func (v *BaseVisitor) acceptLoop(l net.Listener, name string, handleConn func(net.Conn)) {
+ xl := xlog.FromContextSafe(v.ctx)
+ for {
+ conn, err := l.Accept()
+ if err != nil {
+ xl.Warnf("%s listener closed", name)
+ return
+ }
+ go handleConn(conn)
+ }
+}
+
func (v *BaseVisitor) Close() {
if v.l != nil {
v.l.Close()
@@ -130,3 +150,57 @@ func (v *BaseVisitor) Close() {
v.plugin.Close()
}
}
+
+func (v *BaseVisitor) dialRawVisitorConn(cfg *v1.VisitorBaseConfig) (net.Conn, error) {
+ visitorConn, err := v.helper.ConnectServer()
+ if err != nil {
+ return nil, fmt.Errorf("connect to server error: %v", err)
+ }
+
+ now := time.Now().Unix()
+ targetProxyName := naming.BuildTargetServerProxyName(v.clientCfg.User, cfg.ServerUser, cfg.ServerName)
+ newVisitorConnMsg := &msg.NewVisitorConn{
+ RunID: v.helper.RunID(),
+ ProxyName: targetProxyName,
+ SignKey: util.GetAuthKey(cfg.SecretKey, now),
+ Timestamp: now,
+ UseEncryption: cfg.Transport.UseEncryption,
+ UseCompression: cfg.Transport.UseCompression,
+ }
+ err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
+ if err != nil {
+ visitorConn.Close()
+ return nil, fmt.Errorf("send newVisitorConnMsg to server error: %v", err)
+ }
+
+ var newVisitorConnRespMsg msg.NewVisitorConnResp
+ _ = visitorConn.SetReadDeadline(time.Now().Add(10 * time.Second))
+ err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
+ if err != nil {
+ visitorConn.Close()
+ return nil, fmt.Errorf("read newVisitorConnRespMsg error: %v", err)
+ }
+ _ = visitorConn.SetReadDeadline(time.Time{})
+
+ if newVisitorConnRespMsg.Error != "" {
+ visitorConn.Close()
+ return nil, fmt.Errorf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
+ }
+ return visitorConn, nil
+}
+
+func wrapVisitorConn(conn io.ReadWriteCloser, cfg *v1.VisitorBaseConfig) (io.ReadWriteCloser, func(), error) {
+ rwc := conn
+ if cfg.Transport.UseEncryption {
+ var err error
+ rwc, err = libio.WithEncryption(rwc, []byte(cfg.SecretKey))
+ if err != nil {
+ return nil, func() {}, fmt.Errorf("create encryption stream error: %v", err)
+ }
+ }
+ recycleFn := func() {}
+ if cfg.Transport.UseCompression {
+ rwc, recycleFn = libio.WithCompressionFromPool(rwc)
+ }
+ return rwc, recycleFn, nil
+}
diff --git a/client/visitor/visitor_manager.go b/client/visitor/visitor_manager.go
index b3539c69..b60f7047 100644
--- a/client/visitor/visitor_manager.go
+++ b/client/visitor/visitor_manager.go
@@ -191,6 +191,13 @@ func (vm *Manager) TransferConn(name string, conn net.Conn) error {
return v.AcceptConn(conn)
}
+func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) {
+ vm.mu.RLock()
+ defer vm.mu.RUnlock()
+ cfg, ok := vm.cfgs[name]
+ return cfg, ok
+}
+
type visitorHelperImpl struct {
connectServerFn func() (net.Conn, error)
msgTransporter transport.MessageTransporter
diff --git a/client/visitor/xtcp.go b/client/visitor/xtcp.go
index cdfeb1ab..e7a60895 100644
--- a/client/visitor/xtcp.go
+++ b/client/visitor/xtcp.go
@@ -31,6 +31,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/nathole"
"github.com/fatedier/frp/pkg/transport"
netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -64,10 +65,10 @@ func (sv *XTCPVisitor) Run() (err error) {
if err != nil {
return
}
- go sv.worker()
+ go sv.acceptLoop(sv.l, "xtcp local", sv.handleConn)
}
- go sv.internalConnWorker()
+ go sv.acceptLoop(sv.internalLn, "xtcp internal", sv.handleConn)
go sv.processTunnelStartEvents()
if sv.cfg.KeepTunnelOpen {
sv.retryLimiter = rate.NewLimiter(rate.Every(time.Hour/time.Duration(sv.cfg.MaxRetriesAnHour)), sv.cfg.MaxRetriesAnHour)
@@ -92,30 +93,6 @@ func (sv *XTCPVisitor) Close() {
}
}
-func (sv *XTCPVisitor) worker() {
- xl := xlog.FromContextSafe(sv.ctx)
- for {
- conn, err := sv.l.Accept()
- if err != nil {
- xl.Warnf("xtcp local listener closed")
- return
- }
- go sv.handleConn(conn)
- }
-}
-
-func (sv *XTCPVisitor) internalConnWorker() {
- xl := xlog.FromContextSafe(sv.ctx)
- for {
- conn, err := sv.internalLn.Accept()
- if err != nil {
- xl.Warnf("xtcp internal listener closed")
- return
- }
- go sv.handleConn(conn)
- }
-}
-
func (sv *XTCPVisitor) processTunnelStartEvents() {
for {
select {
@@ -205,20 +182,14 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
return
}
- var muxConnRWCloser io.ReadWriteCloser = tunnelConn
- if sv.cfg.Transport.UseEncryption {
- muxConnRWCloser, err = libio.WithEncryption(muxConnRWCloser, []byte(sv.cfg.SecretKey))
- if err != nil {
- xl.Errorf("create encryption stream error: %v", err)
- tunnelErr = err
- return
- }
- }
- if sv.cfg.Transport.UseCompression {
- var recycleFn func()
- muxConnRWCloser, recycleFn = libio.WithCompressionFromPool(muxConnRWCloser)
- defer recycleFn()
+ muxConnRWCloser, recycleFn, err := wrapVisitorConn(tunnelConn, sv.cfg.GetBaseConfig())
+ if err != nil {
+ xl.Errorf("%v", err)
+ tunnelConn.Close()
+ tunnelErr = err
+ return
}
+ defer recycleFn()
_, _, errs := libio.Join(userConn, muxConnRWCloser)
xl.Debugf("join connections closed")
@@ -280,8 +251,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 := naming.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 +282,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,
@@ -371,6 +343,7 @@ func (ks *KCPTunnelSession) Init(listenConn *net.UDPConn, raddr *net.UDPAddr) er
}
remote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String())
if err != nil {
+ lConn.Close()
return fmt.Errorf("create kcp connection from udp connection error: %v", err)
}
diff --git a/cmd/frpc/sub/nathole.go b/cmd/frpc/sub/nathole.go
index a07d6852..b76c8ab7 100644
--- a/cmd/frpc/sub/nathole.go
+++ b/cmd/frpc/sub/nathole.go
@@ -47,7 +47,7 @@ var natholeDiscoveryCmd = &cobra.Command{
Use: "discover",
Short: "Discover nathole information from stun server",
RunE: func(cmd *cobra.Command, args []string) error {
- // ignore error here, because we can use command line pameters
+ // ignore error here, because we can use command line parameters
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
cfg = &v1.ClientCommonConfig{}
diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go
index ef7fe67f..8651f0b3 100644
--- a/cmd/frpc/sub/proxy.go
+++ b/cmd/frpc/sub/proxy.go
@@ -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)
+}
diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go
index 1c2d8d5e..5d83cabf 100644
--- a/cmd/frpc/sub/root.go
+++ b/cmd/frpc/sub/root.go
@@ -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)
}
diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml
index 5a414137..50a00cfd 100644
--- a/conf/frpc_full_example.toml
+++ b/conf/frpc_full_example.toml
@@ -143,6 +143,9 @@ transport.tls.enable = true
# Proxy names you want to start.
# Default is empty, means all proxies.
+# This list is a global allowlist after config + store are merged, so entries
+# created via Store API are also filtered by this list.
+# If start is non-empty, any proxy/visitor not listed here will not be started.
# start = ["ssh", "dns"]
# Alternative to 'start': You can control each proxy individually using the 'enabled' field.
diff --git a/doc/agents/release.md b/doc/agents/release.md
new file mode 100644
index 00000000..d8ec6267
--- /dev/null
+++ b/doc/agents/release.md
@@ -0,0 +1,80 @@
+# Release Process
+
+## 1. Update Release Notes
+
+Edit `Release.md` in the project root with the changes for this version:
+
+```markdown
+## Features
+* ...
+
+## Improvements
+* ...
+
+## Fixes
+* ...
+```
+
+This file is used by GoReleaser as the GitHub Release body.
+
+## 2. Bump Version
+
+Update the version string in `pkg/util/version/version.go`:
+
+```go
+var version = "0.X.0"
+```
+
+Commit and push to `dev`:
+
+```bash
+git add pkg/util/version/version.go Release.md
+git commit -m "bump version to vX.Y.Z"
+git push origin dev
+```
+
+## 3. Merge dev → master
+
+Create a PR from `dev` to `master`:
+
+```bash
+gh pr create --base master --head dev --title "bump version"
+```
+
+Wait for CI to pass, then merge using **merge commit** (not squash).
+
+## 4. Tag the Release
+
+```bash
+git checkout master
+git pull origin master
+git tag -a vX.Y.Z -m "bump version"
+git push origin vX.Y.Z
+```
+
+## 5. Trigger GoReleaser
+
+Manually trigger the `goreleaser` workflow in GitHub Actions:
+
+```bash
+gh workflow run goreleaser --ref master
+```
+
+GoReleaser will:
+1. Run `package.sh` to cross-compile all platforms and create archives
+2. Create a GitHub Release with all packages, using `Release.md` as release notes
+
+## Key Files
+
+| File | Purpose |
+|------|---------|
+| `pkg/util/version/version.go` | Version string |
+| `Release.md` | Release notes (read by GoReleaser) |
+| `.goreleaser.yml` | GoReleaser config |
+| `package.sh` | Cross-compile and packaging script |
+| `.github/workflows/goreleaser.yml` | GitHub Actions workflow (manual trigger) |
+
+## Versioning
+
+- Minor release: `v0.X.0`
+- Patch release: `v0.X.Y` (e.g., `v0.62.1`)
diff --git a/dockerfiles/Dockerfile-for-frpc b/dockerfiles/Dockerfile-for-frpc
index df25542e..881b0cc2 100644
--- a/dockerfiles/Dockerfile-for-frpc
+++ b/dockerfiles/Dockerfile-for-frpc
@@ -5,7 +5,7 @@ COPY web/frpc/ ./
RUN npm install
RUN npm run build
-FROM golang:1.24 AS building
+FROM golang:1.25 AS building
COPY . /building
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
diff --git a/dockerfiles/Dockerfile-for-frps b/dockerfiles/Dockerfile-for-frps
index 5a86b2b6..3df253e2 100644
--- a/dockerfiles/Dockerfile-for-frps
+++ b/dockerfiles/Dockerfile-for-frps
@@ -5,7 +5,7 @@ COPY web/frps/ ./
RUN npm install
RUN npm run build
-FROM golang:1.24 AS building
+FROM golang:1.25 AS building
COPY . /building
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
diff --git a/go.mod b/go.mod
index e23facde..bfede7a9 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/fatedier/frp
-go 1.24.0
+go 1.25.0
require (
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go
index d9377f32..826a6715 100644
--- a/pkg/auth/oidc.go
+++ b/pkg/auth/oidc.go
@@ -23,12 +23,14 @@ import (
"net/url"
"os"
"slices"
+ "sync"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
)
@@ -74,14 +76,64 @@ func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyUR
return &http.Client{Transport: transport}, nil
}
+// nonCachingTokenSource wraps a clientcredentials.Config to fetch a fresh
+// token on every call. This is used as a fallback when the OIDC provider
+// does not return expires_in, which would cause a caching TokenSource to
+// hold onto a stale token forever.
+type nonCachingTokenSource struct {
+ cfg *clientcredentials.Config
+ ctx context.Context
+}
+
+func (s *nonCachingTokenSource) Token() (*oauth2.Token, error) {
+ return s.cfg.Token(s.ctx)
+}
+
+// oidcTokenSource wraps a caching oauth2.TokenSource and, on the first
+// successful Token() call, checks whether the provider returns an expiry.
+// If not, it permanently switches to nonCachingTokenSource so that a fresh
+// token is fetched every time. This avoids an eager network call at
+// construction time, letting the login retry loop handle transient IdP
+// outages.
+type oidcTokenSource struct {
+ mu sync.Mutex
+ initialized bool
+ source oauth2.TokenSource
+ fallbackCfg *clientcredentials.Config
+ fallbackCtx context.Context
+}
+
+func (s *oidcTokenSource) Token() (*oauth2.Token, error) {
+ s.mu.Lock()
+ if !s.initialized {
+ token, err := s.source.Token()
+ if err != nil {
+ s.mu.Unlock()
+ return nil, err
+ }
+ if token.Expiry.IsZero() {
+ s.source = &nonCachingTokenSource{cfg: s.fallbackCfg, ctx: s.fallbackCtx}
+ }
+ s.initialized = true
+ s.mu.Unlock()
+ return token, nil
+ }
+ source := s.source
+ s.mu.Unlock()
+ return source.Token()
+}
+
type OidcAuthProvider struct {
additionalAuthScopes []v1.AuthScope
- tokenGenerator *clientcredentials.Config
- httpClient *http.Client
+ tokenSource oauth2.TokenSource
}
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
+ if err := validation.ValidateOIDCClientCredentialsConfig(&cfg); err != nil {
+ return nil, err
+ }
+
eps := make(map[string][]string)
for k, v := range cfg.AdditionalEndpointParams {
eps[k] = []string{v}
@@ -99,30 +151,42 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
EndpointParams: eps,
}
- // Create custom HTTP client if needed
- var httpClient *http.Client
+ // Build the context that TokenSource will use for all future HTTP requests.
+ // context.Background() is appropriate here because the token source is
+ // long-lived and outlives any single request.
+ ctx := context.Background()
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
- var err error
- httpClient, err = createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
+ httpClient, err := createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
}
+ ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
}
+ // Create a persistent TokenSource that caches the token and refreshes
+ // it before expiry. This avoids making a new HTTP request to the OIDC
+ // provider on every heartbeat/ping.
+ //
+ // We wrap it in an oidcTokenSource so that the first Token() call
+ // (deferred to SetLogin inside the login retry loop) probes whether the
+ // provider returns expires_in. If not, it switches to a non-caching
+ // source. This avoids an eager network call at construction time, which
+ // would prevent loopLoginUntilSuccess from retrying on transient IdP
+ // outages.
+ cachingSource := tokenGenerator.TokenSource(ctx)
+
return &OidcAuthProvider{
additionalAuthScopes: additionalAuthScopes,
- tokenGenerator: tokenGenerator,
- httpClient: httpClient,
+ tokenSource: &oidcTokenSource{
+ source: cachingSource,
+ fallbackCfg: tokenGenerator,
+ fallbackCtx: ctx,
+ },
}, nil
}
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
- ctx := context.Background()
- if auth.httpClient != nil {
- ctx = context.WithValue(ctx, oauth2.HTTPClient, auth.httpClient)
- }
-
- tokenObj, err := auth.tokenGenerator.Token(ctx)
+ tokenObj, err := auth.tokenSource.Token()
if err != nil {
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
}
@@ -205,7 +269,8 @@ type OidcAuthConsumer struct {
additionalAuthScopes []v1.AuthScope
verifier TokenVerifier
- subjectsFromLogin []string
+ mu sync.RWMutex
+ subjectsFromLogin map[string]struct{}
}
func NewTokenVerifier(cfg v1.AuthOIDCServerConfig) TokenVerifier {
@@ -226,7 +291,7 @@ func NewOidcAuthVerifier(additionalAuthScopes []v1.AuthScope, verifier TokenVeri
return &OidcAuthConsumer{
additionalAuthScopes: additionalAuthScopes,
verifier: verifier,
- subjectsFromLogin: []string{},
+ subjectsFromLogin: make(map[string]struct{}),
}
}
@@ -235,9 +300,9 @@ func (auth *OidcAuthConsumer) VerifyLogin(loginMsg *msg.Login) (err error) {
if err != nil {
return fmt.Errorf("invalid OIDC token in login: %v", err)
}
- if !slices.Contains(auth.subjectsFromLogin, token.Subject) {
- auth.subjectsFromLogin = append(auth.subjectsFromLogin, token.Subject)
- }
+ auth.mu.Lock()
+ auth.subjectsFromLogin[token.Subject] = struct{}{}
+ auth.mu.Unlock()
return nil
}
@@ -246,11 +311,13 @@ func (auth *OidcAuthConsumer) verifyPostLoginToken(privilegeKey string) (err err
if err != nil {
return fmt.Errorf("invalid OIDC token in ping: %v", err)
}
- if !slices.Contains(auth.subjectsFromLogin, token.Subject) {
+ auth.mu.RLock()
+ _, ok := auth.subjectsFromLogin[token.Subject]
+ auth.mu.RUnlock()
+ if !ok {
return fmt.Errorf("received different OIDC subject in login and ping. "+
- "original subjects: %s, "+
"new subject: %s",
- auth.subjectsFromLogin, token.Subject)
+ token.Subject)
}
return nil
}
diff --git a/pkg/auth/oidc_test.go b/pkg/auth/oidc_test.go
index 58054186..70e59883 100644
--- a/pkg/auth/oidc_test.go
+++ b/pkg/auth/oidc_test.go
@@ -2,6 +2,10 @@ package auth_test
import (
"context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
"testing"
"time"
@@ -62,3 +66,188 @@ func TestPingAfterLoginWithDifferentSubjectFails(t *testing.T) {
r.Error(err)
r.Contains(err.Error(), "received different OIDC subject in login and ping")
}
+
+func TestOidcAuthProviderFallsBackWhenNoExpiry(t *testing.T) {
+ r := require.New(t)
+
+ var requestCount atomic.Int32
+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ requestCount.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
+ "access_token": "fresh-test-token",
+ "token_type": "Bearer",
+ })
+ }))
+ defer tokenServer.Close()
+
+ provider, err := auth.NewOidcAuthSetter(
+ []v1.AuthScope{v1.AuthScopeHeartBeats},
+ v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ TokenEndpointURL: tokenServer.URL,
+ },
+ )
+ r.NoError(err)
+
+ // Constructor no longer fetches a token eagerly.
+ // The first SetLogin triggers the adaptive probe.
+ r.Equal(int32(0), requestCount.Load())
+
+ loginMsg := &msg.Login{}
+ err = provider.SetLogin(loginMsg)
+ r.NoError(err)
+ r.Equal("fresh-test-token", loginMsg.PrivilegeKey)
+
+ for range 3 {
+ pingMsg := &msg.Ping{}
+ err = provider.SetPing(pingMsg)
+ r.NoError(err)
+ r.Equal("fresh-test-token", pingMsg.PrivilegeKey)
+ }
+
+ // 1 probe (login) + 3 pings = 4 requests (probe doubles as the login token fetch)
+ r.Equal(int32(4), requestCount.Load(), "each call should fetch a fresh token when expires_in is missing")
+}
+
+func TestOidcAuthProviderCachesToken(t *testing.T) {
+ r := require.New(t)
+
+ var requestCount atomic.Int32
+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ requestCount.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
+ "access_token": "cached-test-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ })
+ }))
+ defer tokenServer.Close()
+
+ provider, err := auth.NewOidcAuthSetter(
+ []v1.AuthScope{v1.AuthScopeHeartBeats},
+ v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ TokenEndpointURL: tokenServer.URL,
+ },
+ )
+ r.NoError(err)
+
+ // Constructor no longer fetches eagerly; first SetLogin triggers the probe.
+ r.Equal(int32(0), requestCount.Load())
+
+ // SetLogin triggers the adaptive probe and caches the token.
+ loginMsg := &msg.Login{}
+ err = provider.SetLogin(loginMsg)
+ r.NoError(err)
+ r.Equal("cached-test-token", loginMsg.PrivilegeKey)
+ r.Equal(int32(1), requestCount.Load())
+
+ // Subsequent calls should also reuse the cached token
+ for range 5 {
+ pingMsg := &msg.Ping{}
+ err = provider.SetPing(pingMsg)
+ r.NoError(err)
+ r.Equal("cached-test-token", pingMsg.PrivilegeKey)
+ }
+ r.Equal(int32(1), requestCount.Load(), "token endpoint should only be called once; cached token should be reused")
+}
+
+func TestOidcAuthProviderRetriesOnInitialFailure(t *testing.T) {
+ r := require.New(t)
+
+ var requestCount atomic.Int32
+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ n := requestCount.Add(1)
+ // The oauth2 library retries once internally, so we need two
+ // consecutive failures to surface an error to the caller.
+ if n <= 2 {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": "temporarily_unavailable",
+ "error_description": "service is starting up",
+ })
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{ //nolint:gosec // test-only dummy token response
+ "access_token": "retry-test-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ })
+ }))
+ defer tokenServer.Close()
+
+ // Constructor succeeds even though the IdP is "down".
+ provider, err := auth.NewOidcAuthSetter(
+ []v1.AuthScope{v1.AuthScopeHeartBeats},
+ v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ TokenEndpointURL: tokenServer.URL,
+ },
+ )
+ r.NoError(err)
+ r.Equal(int32(0), requestCount.Load())
+
+ // First SetLogin hits the IdP, which returns an error (after internal retry).
+ loginMsg := &msg.Login{}
+ err = provider.SetLogin(loginMsg)
+ r.Error(err)
+ r.Equal(int32(2), requestCount.Load())
+
+ // Second SetLogin retries and succeeds.
+ err = provider.SetLogin(loginMsg)
+ r.NoError(err)
+ r.Equal("retry-test-token", loginMsg.PrivilegeKey)
+ r.Equal(int32(3), requestCount.Load())
+
+ // Subsequent calls use cached token.
+ pingMsg := &msg.Ping{}
+ err = provider.SetPing(pingMsg)
+ r.NoError(err)
+ r.Equal("retry-test-token", pingMsg.PrivilegeKey)
+ r.Equal(int32(3), requestCount.Load())
+}
+
+func TestNewOidcAuthSetterRejectsInvalidStaticConfig(t *testing.T) {
+ r := require.New(t)
+ tokenServer := httptest.NewServer(http.NotFoundHandler())
+ defer tokenServer.Close()
+
+ _, err := auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: "://bad",
+ })
+ r.Error(err)
+ r.Contains(err.Error(), "auth.oidc.tokenEndpointURL")
+
+ _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
+ TokenEndpointURL: tokenServer.URL,
+ })
+ r.Error(err)
+ r.Contains(err.Error(), "auth.oidc.clientID is required")
+
+ _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ AdditionalEndpointParams: map[string]string{
+ "scope": "profile",
+ },
+ })
+ r.Error(err)
+ r.Contains(err.Error(), "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
+
+ _, err = auth.NewOidcAuthSetter(nil, v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ Audience: "api",
+ AdditionalEndpointParams: map[string]string{"audience": "override"},
+ })
+ r.Error(err)
+ r.Contains(err.Error(), "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
+}
diff --git a/pkg/config/legacy/conversion.go b/pkg/config/legacy/conversion.go
index 4ae54f88..ec8d0698 100644
--- a/pkg/config/legacy/conversion.go
+++ b/pkg/config/legacy/conversion.go
@@ -171,15 +171,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
func transformHeadersFromPluginParams(params map[string]string) v1.HeaderOperations {
out := v1.HeaderOperations{}
for k, v := range params {
- if !strings.HasPrefix(k, "plugin_header_") {
+ k, ok := strings.CutPrefix(k, "plugin_header_")
+ if !ok || k == "" {
continue
}
- if k = strings.TrimPrefix(k, "plugin_header_"); k != "" {
- if out.Set == nil {
- out.Set = make(map[string]string)
- }
- out.Set[k] = v
+ if out.Set == nil {
+ out.Set = make(map[string]string)
}
+ out.Set[k] = v
}
return out
}
diff --git a/pkg/config/legacy/proxy.go b/pkg/config/legacy/proxy.go
index 0c461a1a..8d7cf9a0 100644
--- a/pkg/config/legacy/proxy.go
+++ b/pkg/config/legacy/proxy.go
@@ -39,14 +39,14 @@ const (
// Proxy
var (
proxyConfTypeMap = map[ProxyType]reflect.Type{
- ProxyTypeTCP: reflect.TypeOf(TCPProxyConf{}),
- ProxyTypeUDP: reflect.TypeOf(UDPProxyConf{}),
- ProxyTypeTCPMUX: reflect.TypeOf(TCPMuxProxyConf{}),
- ProxyTypeHTTP: reflect.TypeOf(HTTPProxyConf{}),
- ProxyTypeHTTPS: reflect.TypeOf(HTTPSProxyConf{}),
- ProxyTypeSTCP: reflect.TypeOf(STCPProxyConf{}),
- ProxyTypeXTCP: reflect.TypeOf(XTCPProxyConf{}),
- ProxyTypeSUDP: reflect.TypeOf(SUDPProxyConf{}),
+ ProxyTypeTCP: reflect.TypeFor[TCPProxyConf](),
+ ProxyTypeUDP: reflect.TypeFor[UDPProxyConf](),
+ ProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConf](),
+ ProxyTypeHTTP: reflect.TypeFor[HTTPProxyConf](),
+ ProxyTypeHTTPS: reflect.TypeFor[HTTPSProxyConf](),
+ ProxyTypeSTCP: reflect.TypeFor[STCPProxyConf](),
+ ProxyTypeXTCP: reflect.TypeFor[XTCPProxyConf](),
+ ProxyTypeSUDP: reflect.TypeFor[SUDPProxyConf](),
}
)
diff --git a/pkg/config/legacy/utils.go b/pkg/config/legacy/utils.go
index 9366f8c7..ad53f1c5 100644
--- a/pkg/config/legacy/utils.go
+++ b/pkg/config/legacy/utils.go
@@ -22,8 +22,8 @@ func GetMapWithoutPrefix(set map[string]string, prefix string) map[string]string
m := make(map[string]string)
for key, value := range set {
- if strings.HasPrefix(key, prefix) {
- m[strings.TrimPrefix(key, prefix)] = value
+ if trimmed, ok := strings.CutPrefix(key, prefix); ok {
+ m[trimmed] = value
}
}
diff --git a/pkg/config/legacy/visitor.go b/pkg/config/legacy/visitor.go
index 031c29bf..110a214d 100644
--- a/pkg/config/legacy/visitor.go
+++ b/pkg/config/legacy/visitor.go
@@ -32,9 +32,9 @@ const (
// Visitor
var (
visitorConfTypeMap = map[VisitorType]reflect.Type{
- VisitorTypeSTCP: reflect.TypeOf(STCPVisitorConf{}),
- VisitorTypeXTCP: reflect.TypeOf(XTCPVisitorConf{}),
- VisitorTypeSUDP: reflect.TypeOf(SUDPVisitorConf{}),
+ VisitorTypeSTCP: reflect.TypeFor[STCPVisitorConf](),
+ VisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConf](),
+ VisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConf](),
}
)
diff --git a/pkg/config/load.go b/pkg/config/load.go
index 6e8c251d..38634fc5 100644
--- a/pkg/config/load.go
+++ b/pkg/config/load.go
@@ -17,6 +17,7 @@ package config
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -33,6 +34,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -108,7 +110,21 @@ func LoadConfigureFromFile(path string, c any, strict bool) error {
if err != nil {
return err
}
- return LoadConfigure(content, c, strict)
+ return LoadConfigure(content, c, strict, detectFormatFromPath(path))
+}
+
+// detectFormatFromPath returns a format hint based on the file extension.
+func detectFormatFromPath(path string) string {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".toml":
+ return "toml"
+ case ".yaml", ".yml":
+ return "yaml"
+ case ".json":
+ return "json"
+ default:
+ return ""
+ }
}
// parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling
@@ -129,48 +145,136 @@ func parseYAMLWithDotFieldsHandling(content []byte, target any) error {
}
// Convert to JSON and decode with strict validation
- jsonBytes, err := json.Marshal(temp)
+ jsonBytes, err := jsonx.Marshal(temp)
if err != nil {
return err
}
- decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
- decoder.DisallowUnknownFields()
- return decoder.Decode(target)
+ return decodeJSONContent(jsonBytes, target, true)
+}
+
+func decodeJSONContent(content []byte, target any, strict bool) error {
+ if clientCfg, ok := target.(*v1.ClientConfig); ok {
+ decoded, err := v1.DecodeClientConfigJSON(content, v1.DecodeOptions{
+ DisallowUnknownFields: strict,
+ })
+ if err != nil {
+ return err
+ }
+ *clientCfg = decoded
+ return nil
+ }
+
+ return jsonx.UnmarshalWithOptions(content, target, jsonx.DecodeOptions{
+ RejectUnknownMembers: strict,
+ })
}
// LoadConfigure loads configuration from bytes and unmarshal into c.
// Now it supports json, yaml and toml format.
-func LoadConfigure(b []byte, c any, strict bool) error {
- v1.DisallowUnknownFieldsMu.Lock()
- defer v1.DisallowUnknownFieldsMu.Unlock()
- v1.DisallowUnknownFields = strict
+// An optional format hint (e.g. "toml", "yaml", "json") can be provided
+// to enable better error messages with line number information.
+func LoadConfigure(b []byte, c any, strict bool, formats ...string) error {
+ format := ""
+ if len(formats) > 0 {
+ format = formats[0]
+ }
+
+ originalBytes := b
+ parsedFromTOML := false
var tomlObj any
- // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
- if err := toml.Unmarshal(b, &tomlObj); err == nil {
- b, err = json.Marshal(&tomlObj)
+ tomlErr := toml.Unmarshal(b, &tomlObj)
+ if tomlErr == nil {
+ parsedFromTOML = true
+ var err error
+ b, err = jsonx.Marshal(&tomlObj)
if err != nil {
return err
}
+ } else if format == "toml" {
+ // File is known to be TOML but has syntax errors.
+ return formatTOMLError(tomlErr)
}
+
// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
if yaml.IsJSONBuffer(b) {
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if strict {
- decoder.DisallowUnknownFields()
+ if err := decodeJSONContent(b, c, strict); err != nil {
+ return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
}
- return decoder.Decode(c)
+ return nil
}
// Handle YAML content
if strict {
// In strict mode, always use our custom handler to support YAML merge
- return parseYAMLWithDotFieldsHandling(b, c)
+ if err := parseYAMLWithDotFieldsHandling(b, c); err != nil {
+ return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
+ }
+ return nil
}
// Non-strict mode, parse normally
return yaml.Unmarshal(b, c)
}
+// formatTOMLError extracts line/column information from TOML decode errors.
+func formatTOMLError(err error) error {
+ var decErr *toml.DecodeError
+ if errors.As(err, &decErr) {
+ row, col := decErr.Position()
+ return fmt.Errorf("toml: line %d, column %d: %s", row, col, decErr.Error())
+ }
+ var strictErr *toml.StrictMissingError
+ if errors.As(err, &strictErr) {
+ return strictErr
+ }
+ return err
+}
+
+// enhanceDecodeError tries to add field path and line number information to JSON/YAML decode errors.
+func enhanceDecodeError(err error, originalContent []byte, includeLine bool) error {
+ var typeErr *json.UnmarshalTypeError
+ if errors.As(err, &typeErr) && typeErr.Field != "" {
+ if includeLine {
+ line := findFieldLineInContent(originalContent, typeErr.Field)
+ if line > 0 {
+ return fmt.Errorf("line %d: field \"%s\": cannot unmarshal %s into %s", line, typeErr.Field, typeErr.Value, typeErr.Type)
+ }
+ }
+ return fmt.Errorf("field \"%s\": cannot unmarshal %s into %s", typeErr.Field, typeErr.Value, typeErr.Type)
+ }
+ return err
+}
+
+// findFieldLineInContent searches the original config content for a field name
+// and returns the 1-indexed line number where it appears, or 0 if not found.
+func findFieldLineInContent(content []byte, fieldPath string) int {
+ if fieldPath == "" {
+ return 0
+ }
+
+ // Use the last component of the field path (e.g. "proxies" from "proxies" or
+ // "protocol" from "transport.protocol").
+ parts := strings.Split(fieldPath, ".")
+ searchKey := parts[len(parts)-1]
+
+ lines := bytes.Split(content, []byte("\n"))
+ for i, line := range lines {
+ trimmed := bytes.TrimSpace(line)
+ // Match TOML key assignments like: key = ...
+ if bytes.HasPrefix(trimmed, []byte(searchKey)) {
+ rest := bytes.TrimSpace(trimmed[len(searchKey):])
+ if len(rest) > 0 && rest[0] == '=' {
+ return i + 1
+ }
+ }
+ // Match TOML table array headers like: [[proxies]]
+ if bytes.Contains(trimmed, []byte("[["+searchKey+"]]")) {
+ return i + 1
+ }
+ }
+ return 0
+}
+
func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {
m.ProxyType = util.EmptyOr(m.ProxyType, string(v1.ProxyTypeTCP))
@@ -180,7 +284,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 +323,132 @@ 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{}
}
- // Filter by start
- if len(cliCfg.Start) > 0 {
- startSet := sets.New(cliCfg.Start...)
+ proxyCfgs := proxies
+ visitorCfgs := visitors
+
+ // Filter by start across merged configurers from all sources.
+ // For example, store entries are also filtered by this set.
+ 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 +467,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) {
diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go
index 95d6101e..b711a5c1 100644
--- a/pkg/config/load_test.go
+++ b/pkg/config/load_test.go
@@ -15,6 +15,7 @@
package config
import (
+ "encoding/json"
"fmt"
"strings"
"testing"
@@ -188,6 +189,31 @@ unixPath = "/tmp/uds.sock"
require.Error(err)
}
+func TestLoadClientConfigStrictMode_UnknownPluginField(t *testing.T) {
+ require := require.New(t)
+
+ content := `
+serverPort = 7000
+
+[[proxies]]
+name = "test"
+type = "tcp"
+localPort = 6000
+[proxies.plugin]
+type = "http2https"
+localAddr = "127.0.0.1:8080"
+unknownInPlugin = "value"
+`
+
+ clientCfg := v1.ClientConfig{}
+
+ err := LoadConfigure([]byte(content), &clientCfg, false)
+ require.NoError(err)
+
+ err = LoadConfigure([]byte(content), &clientCfg, true)
+ require.ErrorContains(err, "unknownInPlugin")
+}
+
// TestYAMLMergeInStrictMode tests that YAML merge functionality works
// even in strict mode by properly handling dot-prefixed fields
func TestYAMLMergeInStrictMode(t *testing.T) {
@@ -273,6 +299,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)
@@ -306,3 +495,111 @@ serverPort: 7000
require.Equal("127.0.0.1", clientCfg.ServerAddr)
require.Equal(7000, clientCfg.ServerPort)
}
+
+func TestTOMLSyntaxErrorWithPosition(t *testing.T) {
+ require := require.New(t)
+
+ // TOML with syntax error (unclosed table array header)
+ content := `serverAddr = "127.0.0.1"
+serverPort = 7000
+
+[[proxies]
+name = "test"
+`
+
+ clientCfg := v1.ClientConfig{}
+ err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
+ require.Error(err)
+ require.Contains(err.Error(), "toml")
+ require.Contains(err.Error(), "line")
+ require.Contains(err.Error(), "column")
+}
+
+func TestTOMLTypeMismatchErrorWithFieldInfo(t *testing.T) {
+ require := require.New(t)
+
+ // TOML with wrong type: proxies should be a table array, not a string
+ content := `serverAddr = "127.0.0.1"
+serverPort = 7000
+proxies = "this should be a table array"
+`
+
+ clientCfg := v1.ClientConfig{}
+ err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
+ require.Error(err)
+ // The error should contain field info
+ errMsg := err.Error()
+ require.Contains(errMsg, "proxies")
+ require.NotContains(errMsg, "line")
+}
+
+func TestFindFieldLineInContent(t *testing.T) {
+ content := []byte(`serverAddr = "127.0.0.1"
+serverPort = 7000
+
+[[proxies]]
+name = "test"
+type = "tcp"
+remotePort = 6000
+`)
+
+ tests := []struct {
+ fieldPath string
+ wantLine int
+ }{
+ {"serverAddr", 1},
+ {"serverPort", 2},
+ {"name", 5},
+ {"type", 6},
+ {"remotePort", 7},
+ {"nonexistent", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.fieldPath, func(t *testing.T) {
+ got := findFieldLineInContent(content, tt.fieldPath)
+ require.Equal(t, tt.wantLine, got)
+ })
+ }
+}
+
+func TestFormatDetection(t *testing.T) {
+ tests := []struct {
+ path string
+ format string
+ }{
+ {"config.toml", "toml"},
+ {"config.TOML", "toml"},
+ {"config.yaml", "yaml"},
+ {"config.yml", "yaml"},
+ {"config.json", "json"},
+ {"config.ini", ""},
+ {"config", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ require.Equal(t, tt.format, detectFormatFromPath(tt.path))
+ })
+ }
+}
+
+func TestValidTOMLStillWorks(t *testing.T) {
+ require := require.New(t)
+
+ // Valid TOML with format hint should work fine
+ content := `serverAddr = "127.0.0.1"
+serverPort = 7000
+
+[[proxies]]
+name = "test"
+type = "tcp"
+remotePort = 6000
+`
+ clientCfg := v1.ClientConfig{}
+ err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
+ require.NoError(err)
+ require.Equal("127.0.0.1", clientCfg.ServerAddr)
+ require.Equal(7000, clientCfg.ServerPort)
+ require.Len(clientCfg.Proxies, 1)
+}
diff --git a/pkg/config/source/aggregator.go b/pkg/config/source/aggregator.go
new file mode 100644
index 00000000..58496932
--- /dev/null
+++ b/pkg/config/source/aggregator.go
@@ -0,0 +1,109 @@
+// 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 (
+ "cmp"
+ "errors"
+ "fmt"
+ "maps"
+ "slices"
+ "sync"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+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() []Source {
+ sources := make([]Source, 0, 2)
+ if a.configSource != nil {
+ sources = append(sources, a.configSource)
+ }
+ if a.storeSource != nil {
+ sources = append(sources, 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 _, src := range entries {
+ proxies, visitors, err := src.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 := slices.SortedFunc(maps.Values(proxyMap), func(x, y v1.ProxyConfigurer) int {
+ return cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name)
+ })
+ visitors := slices.SortedFunc(maps.Values(visitorMap), func(x, y v1.VisitorConfigurer) int {
+ return cmp.Compare(x.GetBaseConfig().Name, y.GetBaseConfig().Name)
+ })
+ return proxies, visitors
+}
diff --git a/pkg/config/source/aggregator_test.go b/pkg/config/source/aggregator_test.go
new file mode 100644
index 00000000..380c05cf
--- /dev/null
+++ b/pkg/config/source/aggregator_test.go
@@ -0,0 +1,238 @@
+// 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_ReturnsSortedByName(t *testing.T) {
+ require := require.New(t)
+
+ agg := newTestAggregator(t, nil)
+ err := agg.ConfigSource().ReplaceAll(
+ []v1.ProxyConfigurer{mockProxy("charlie"), mockProxy("alice"), mockProxy("bob")},
+ []v1.VisitorConfigurer{mockVisitor("zulu"), mockVisitor("alpha")},
+ )
+ require.NoError(err)
+
+ proxies, visitors, err := agg.Load()
+ require.NoError(err)
+ require.Len(proxies, 3)
+ require.Equal("alice", proxies[0].GetBaseConfig().Name)
+ require.Equal("bob", proxies[1].GetBaseConfig().Name)
+ require.Equal("charlie", proxies[2].GetBaseConfig().Name)
+ require.Len(visitors, 2)
+ require.Equal("alpha", visitors[0].GetBaseConfig().Name)
+ require.Equal("zulu", visitors[1].GetBaseConfig().Name)
+}
+
+func TestAggregator_Load_ReturnsDefensiveCopies(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("ssh", proxies2[0].GetBaseConfig().Name)
+}
diff --git a/pkg/config/source/base_source.go b/pkg/config/source/base_source.go
new file mode 100644
index 00000000..ab1f59fe
--- /dev/null
+++ b/pkg/config/source/base_source.go
@@ -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 cloneConfigurers(proxies, visitors)
+}
diff --git a/pkg/config/source/base_source_test.go b/pkg/config/source/base_source_test.go
new file mode 100644
index 00000000..34ea9d9d
--- /dev/null
+++ b/pkg/config/source/base_source_test.go
@@ -0,0 +1,48 @@
+package source
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func TestBaseSourceLoadReturnsClonedConfigurers(t *testing.T) {
+ require := require.New(t)
+
+ src := NewConfigSource()
+
+ proxyCfg := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "proxy1",
+ Type: "tcp",
+ },
+ }
+ visitorCfg := &v1.STCPVisitorConfig{
+ VisitorBaseConfig: v1.VisitorBaseConfig{
+ Name: "visitor1",
+ Type: "stcp",
+ },
+ }
+
+ err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
+ require.NoError(err)
+
+ firstProxies, firstVisitors, err := src.Load()
+ require.NoError(err)
+ require.Len(firstProxies, 1)
+ require.Len(firstVisitors, 1)
+
+ // Mutate loaded objects as runtime completion would do.
+ firstProxies[0].Complete()
+ firstVisitors[0].Complete()
+
+ secondProxies, secondVisitors, err := src.Load()
+ require.NoError(err)
+ require.Len(secondProxies, 1)
+ require.Len(secondVisitors, 1)
+
+ require.Empty(secondProxies[0].GetBaseConfig().LocalIP)
+ require.Empty(secondVisitors[0].GetBaseConfig().BindAddr)
+}
diff --git a/pkg/config/source/clone.go b/pkg/config/source/clone.go
new file mode 100644
index 00000000..cacd5dbb
--- /dev/null
+++ b/pkg/config/source/clone.go
@@ -0,0 +1,43 @@
+// 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"
+)
+
+func cloneConfigurers(
+ proxies []v1.ProxyConfigurer,
+ visitors []v1.VisitorConfigurer,
+) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
+ clonedProxies := make([]v1.ProxyConfigurer, 0, len(proxies))
+ clonedVisitors := make([]v1.VisitorConfigurer, 0, len(visitors))
+
+ for _, cfg := range proxies {
+ if cfg == nil {
+ return nil, nil, fmt.Errorf("proxy cannot be nil")
+ }
+ clonedProxies = append(clonedProxies, cfg.Clone())
+ }
+ for _, cfg := range visitors {
+ if cfg == nil {
+ return nil, nil, fmt.Errorf("visitor cannot be nil")
+ }
+ clonedVisitors = append(clonedVisitors, cfg.Clone())
+ }
+ return clonedProxies, clonedVisitors, nil
+}
diff --git a/pkg/config/source/config_source.go b/pkg/config/source/config_source.go
new file mode 100644
index 00000000..ea8a2af6
--- /dev/null
+++ b/pkg/config/source/config_source.go
@@ -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
+}
diff --git a/pkg/config/source/config_source_test.go b/pkg/config/source/config_source_test.go
new file mode 100644
index 00000000..793284e1
--- /dev/null
+++ b/pkg/config/source/config_source_test.go
@@ -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)
+}
diff --git a/pkg/config/source/source.go b/pkg/config/source/source.go
new file mode 100644
index 00000000..a6cff226
--- /dev/null
+++ b/pkg/config/source/source.go
@@ -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)
+}
diff --git a/pkg/config/source/store.go b/pkg/config/source/store.go
new file mode 100644
index 00000000..d1bf4fb5
--- /dev/null
+++ b/pkg/config/source/store.go
@@ -0,0 +1,367 @@
+// 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"
+ "os"
+ "path/filepath"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/util/jsonx"
+)
+
+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
+}
+
+var (
+ ErrAlreadyExists = errors.New("already exists")
+ ErrNotFound = errors.New("not found")
+)
+
+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
+ }
+
+ type rawStoreData struct {
+ Proxies []jsonx.RawMessage `json:"proxies,omitempty"`
+ Visitors []jsonx.RawMessage `json:"visitors,omitempty"`
+ }
+ stored := rawStoreData{}
+ if err := jsonx.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 i, proxyData := range stored.Proxies {
+ proxyCfg, err := v1.DecodeProxyConfigurerJSON(proxyData, v1.DecodeOptions{
+ DisallowUnknownFields: false,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to decode proxy at index %d: %w", i, err)
+ }
+ name := proxyCfg.GetBaseConfig().Name
+ if name == "" {
+ return fmt.Errorf("proxy name cannot be empty")
+ }
+ s.proxies[name] = proxyCfg
+ }
+
+ for i, visitorData := range stored.Visitors {
+ visitorCfg, err := v1.DecodeVisitorConfigurerJSON(visitorData, v1.DecodeOptions{
+ DisallowUnknownFields: false,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to decode visitor at index %d: %w", i, err)
+ }
+ 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 := jsonx.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("%w: proxy %q", ErrAlreadyExists, 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("%w: proxy %q", ErrNotFound, 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("%w: proxy %q", ErrNotFound, 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("%w: visitor %q", ErrAlreadyExists, 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("%w: visitor %q", ErrNotFound, 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("%w: visitor %q", ErrNotFound, 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
+}
diff --git a/pkg/config/source/store_test.go b/pkg/config/source/store_test.go
new file mode 100644
index 00000000..bb5382b0
--- /dev/null
+++ b/pkg/config/source/store_test.go
@@ -0,0 +1,121 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+ "github.com/fatedier/frp/pkg/util/jsonx"
+)
+
+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 := jsonx.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)
+}
+
+func TestStoreSource_LoadFromFile_UnknownFieldsAreIgnored(t *testing.T) {
+ require := require.New(t)
+
+ path := filepath.Join(t.TempDir(), "store.json")
+ raw := []byte(`{
+ "proxies": [
+ {"name":"proxy1","type":"tcp","localPort":10080,"unexpected":"value"}
+ ],
+ "visitors": [
+ {"name":"visitor1","type":"xtcp","serverName":"server1","secretKey":"secret","bindPort":10081,"unexpected":"value"}
+ ]
+ }`)
+ err := os.WriteFile(path, raw, 0o600)
+ require.NoError(err)
+
+ storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
+ require.NoError(err)
+
+ require.NotNil(storeSource.GetProxy("proxy1"))
+ require.NotNil(storeSource.GetVisitor("visitor1"))
+}
diff --git a/pkg/config/template.go b/pkg/config/template.go
index 44bc456d..16fe069f 100644
--- a/pkg/config/template.go
+++ b/pkg/config/template.go
@@ -38,7 +38,7 @@ func parseNumberRangePair(firstRangeStr, secondRangeStr string) ([]NumberPair, e
return nil, fmt.Errorf("first and second range numbers are not in pairs")
}
pairs := make([]NumberPair, 0, len(firstRangeNumbers))
- for i := 0; i < len(firstRangeNumbers); i++ {
+ for i := range firstRangeNumbers {
pairs = append(pairs, NumberPair{
First: firstRangeNumbers[i],
Second: secondRangeNumbers[i],
diff --git a/pkg/config/types/types.go b/pkg/config/types/types.go
index 8fa3105a..972908ed 100644
--- a/pkg/config/types/types.go
+++ b/pkg/config/types/types.go
@@ -70,24 +70,18 @@ func (q *BandwidthQuantity) UnmarshalString(s string) error {
f float64
err error
)
- switch {
- case strings.HasSuffix(s, "MB"):
+ if fstr, ok := strings.CutSuffix(s, "MB"); ok {
base = MB
- fstr := strings.TrimSuffix(s, "MB")
f, err = strconv.ParseFloat(fstr, 64)
- if err != nil {
- return err
- }
- case strings.HasSuffix(s, "KB"):
+ } else if fstr, ok := strings.CutSuffix(s, "KB"); ok {
base = KB
- fstr := strings.TrimSuffix(s, "KB")
f, err = strconv.ParseFloat(fstr, 64)
- if err != nil {
- return err
- }
- default:
+ } else {
return errors.New("unit not support")
}
+ if err != nil {
+ return err
+ }
q.s = s
q.i = int64(f * float64(base))
@@ -143,8 +137,8 @@ func (p PortsRangeSlice) String() string {
func NewPortsRangeSliceFromString(str string) ([]PortsRange, error) {
str = strings.TrimSpace(str)
out := []PortsRange{}
- numRanges := strings.Split(str, ",")
- for _, numRangeStr := range numRanges {
+ numRanges := strings.SplitSeq(str, ",")
+ for numRangeStr := range numRanges {
// 1000-2000 or 2001
numArray := strings.Split(numRangeStr, "-")
// length: only 1 or 2 is correct
diff --git a/pkg/config/types/types_test.go b/pkg/config/types/types_test.go
index 8843de5a..c05ac9ee 100644
--- a/pkg/config/types/types_test.go
+++ b/pkg/config/types/types_test.go
@@ -39,6 +39,31 @@ func TestBandwidthQuantity(t *testing.T) {
require.Equal(`{"b":"1KB","int":5}`, string(buf))
}
+func TestBandwidthQuantity_MB(t *testing.T) {
+ require := require.New(t)
+
+ var w Wrap
+ err := json.Unmarshal([]byte(`{"b":"2MB","int":1}`), &w)
+ require.NoError(err)
+ require.EqualValues(2*MB, w.B.Bytes())
+
+ buf, err := json.Marshal(&w)
+ require.NoError(err)
+ require.Equal(`{"b":"2MB","int":1}`, string(buf))
+}
+
+func TestBandwidthQuantity_InvalidUnit(t *testing.T) {
+ var w Wrap
+ err := json.Unmarshal([]byte(`{"b":"1GB","int":1}`), &w)
+ require.Error(t, err)
+}
+
+func TestBandwidthQuantity_InvalidNumber(t *testing.T) {
+ var w Wrap
+ err := json.Unmarshal([]byte(`{"b":"abcKB","int":1}`), &w)
+ require.Error(t, err)
+}
+
func TestPortsRangeSlice2String(t *testing.T) {
require := require.New(t)
diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go
index bb95b6cd..783eee8e 100644
--- a/pkg/config/v1/client.go
+++ b/pkg/config/v1/client.go
@@ -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 {
diff --git a/pkg/config/v1/clone_test.go b/pkg/config/v1/clone_test.go
new file mode 100644
index 00000000..9b108528
--- /dev/null
+++ b/pkg/config/v1/clone_test.go
@@ -0,0 +1,109 @@
+package v1
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestProxyCloneDeepCopy(t *testing.T) {
+ require := require.New(t)
+
+ enabled := true
+ pluginHTTP2 := true
+ cfg := &HTTPProxyConfig{
+ ProxyBaseConfig: ProxyBaseConfig{
+ Name: "p1",
+ Type: "http",
+ Enabled: &enabled,
+ Annotations: map[string]string{"a": "1"},
+ Metadatas: map[string]string{"m": "1"},
+ HealthCheck: HealthCheckConfig{
+ Type: "http",
+ HTTPHeaders: []HTTPHeader{
+ {Name: "X-Test", Value: "v1"},
+ },
+ },
+ ProxyBackend: ProxyBackend{
+ Plugin: TypedClientPluginOptions{
+ Type: PluginHTTPS2HTTP,
+ ClientPluginOptions: &HTTPS2HTTPPluginOptions{
+ Type: PluginHTTPS2HTTP,
+ EnableHTTP2: &pluginHTTP2,
+ RequestHeaders: HeaderOperations{Set: map[string]string{"k": "v"}},
+ },
+ },
+ },
+ },
+ DomainConfig: DomainConfig{
+ CustomDomains: []string{"a.example.com"},
+ SubDomain: "a",
+ },
+ Locations: []string{"/api"},
+ RequestHeaders: HeaderOperations{Set: map[string]string{"h1": "v1"}},
+ ResponseHeaders: HeaderOperations{Set: map[string]string{"h2": "v2"}},
+ }
+
+ cloned := cfg.Clone().(*HTTPProxyConfig)
+
+ *cloned.Enabled = false
+ cloned.Annotations["a"] = "changed"
+ cloned.Metadatas["m"] = "changed"
+ cloned.HealthCheck.HTTPHeaders[0].Value = "changed"
+ cloned.CustomDomains[0] = "b.example.com"
+ cloned.Locations[0] = "/new"
+ cloned.RequestHeaders.Set["h1"] = "changed"
+ cloned.ResponseHeaders.Set["h2"] = "changed"
+ clientPlugin := cloned.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions)
+ *clientPlugin.EnableHTTP2 = false
+ clientPlugin.RequestHeaders.Set["k"] = "changed"
+
+ require.True(*cfg.Enabled)
+ require.Equal("1", cfg.Annotations["a"])
+ require.Equal("1", cfg.Metadatas["m"])
+ require.Equal("v1", cfg.HealthCheck.HTTPHeaders[0].Value)
+ require.Equal("a.example.com", cfg.CustomDomains[0])
+ require.Equal("/api", cfg.Locations[0])
+ require.Equal("v1", cfg.RequestHeaders.Set["h1"])
+ require.Equal("v2", cfg.ResponseHeaders.Set["h2"])
+
+ origPlugin := cfg.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions)
+ require.True(*origPlugin.EnableHTTP2)
+ require.Equal("v", origPlugin.RequestHeaders.Set["k"])
+}
+
+func TestVisitorCloneDeepCopy(t *testing.T) {
+ require := require.New(t)
+
+ enabled := true
+ cfg := &XTCPVisitorConfig{
+ VisitorBaseConfig: VisitorBaseConfig{
+ Name: "v1",
+ Type: "xtcp",
+ Enabled: &enabled,
+ ServerName: "server",
+ BindPort: 7000,
+ Plugin: TypedVisitorPluginOptions{
+ Type: VisitorPluginVirtualNet,
+ VisitorPluginOptions: &VirtualNetVisitorPluginOptions{
+ Type: VisitorPluginVirtualNet,
+ DestinationIP: "10.0.0.1",
+ },
+ },
+ },
+ NatTraversal: &NatTraversalConfig{
+ DisableAssistedAddrs: true,
+ },
+ }
+
+ cloned := cfg.Clone().(*XTCPVisitorConfig)
+ *cloned.Enabled = false
+ cloned.NatTraversal.DisableAssistedAddrs = false
+ visitorPlugin := cloned.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions)
+ visitorPlugin.DestinationIP = "10.0.0.2"
+
+ require.True(*cfg.Enabled)
+ require.True(cfg.NatTraversal.DisableAssistedAddrs)
+ origPlugin := cfg.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions)
+ require.Equal("10.0.0.1", origPlugin.DestinationIP)
+}
diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go
index 89898554..41bd9a4c 100644
--- a/pkg/config/v1/common.go
+++ b/pkg/config/v1/common.go
@@ -15,23 +15,11 @@
package v1
import (
- "sync"
+ "maps"
"github.com/fatedier/frp/pkg/util/util"
)
-// TODO(fatedier): Due to the current implementation issue of the go json library, the UnmarshalJSON method
-// of a custom struct cannot access the DisallowUnknownFields parameter of the parent decoder.
-// Here, a global variable is temporarily used to control whether unknown fields are allowed.
-// Once the v2 version is implemented by the community, we can switch to a standardized approach.
-//
-// https://github.com/golang/go/issues/41144
-// https://github.com/golang/go/discussions/63397
-var (
- DisallowUnknownFields = false
- DisallowUnknownFieldsMu sync.Mutex
-)
-
type AuthScope string
const (
@@ -104,6 +92,14 @@ type NatTraversalConfig struct {
DisableAssistedAddrs bool `json:"disableAssistedAddrs,omitempty"`
}
+func (c *NatTraversalConfig) Clone() *NatTraversalConfig {
+ if c == nil {
+ return nil
+ }
+ out := *c
+ return &out
+}
+
type LogConfig struct {
// This is destination where frp should write the logs.
// If "console" is used, logs will be printed to stdout, otherwise,
@@ -138,6 +134,12 @@ type HeaderOperations struct {
Set map[string]string `json:"set,omitempty"`
}
+func (o HeaderOperations) Clone() HeaderOperations {
+ return HeaderOperations{
+ Set: maps.Clone(o.Set),
+ }
+}
+
type HTTPHeader struct {
Name string `json:"name"`
Value string `json:"value"`
diff --git a/pkg/config/v1/decode.go b/pkg/config/v1/decode.go
new file mode 100644
index 00000000..427cea7f
--- /dev/null
+++ b/pkg/config/v1/decode.go
@@ -0,0 +1,195 @@
+// 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
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+
+ "github.com/fatedier/frp/pkg/util/jsonx"
+)
+
+type DecodeOptions struct {
+ DisallowUnknownFields bool
+}
+
+func decodeJSONWithOptions(b []byte, out any, options DecodeOptions) error {
+ return jsonx.UnmarshalWithOptions(b, out, jsonx.DecodeOptions{
+ RejectUnknownMembers: options.DisallowUnknownFields,
+ })
+}
+
+func isJSONNull(b []byte) bool {
+ return len(b) == 0 || string(b) == "null"
+}
+
+type typedEnvelope struct {
+ Type string `json:"type"`
+ Plugin jsonx.RawMessage `json:"plugin,omitempty"`
+}
+
+func DecodeProxyConfigurerJSON(b []byte, options DecodeOptions) (ProxyConfigurer, error) {
+ if isJSONNull(b) {
+ return nil, errors.New("type is required")
+ }
+
+ var env typedEnvelope
+ if err := jsonx.Unmarshal(b, &env); err != nil {
+ return nil, err
+ }
+
+ configurer := NewProxyConfigurerByType(ProxyType(env.Type))
+ if configurer == nil {
+ return nil, fmt.Errorf("unknown proxy type: %s", env.Type)
+ }
+ if err := decodeJSONWithOptions(b, configurer, options); err != nil {
+ return nil, fmt.Errorf("unmarshal ProxyConfig error: %v", err)
+ }
+
+ if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) {
+ plugin, err := DecodeClientPluginOptionsJSON(env.Plugin, options)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal proxy plugin error: %v", err)
+ }
+ configurer.GetBaseConfig().Plugin = plugin
+ }
+ return configurer, nil
+}
+
+func DecodeVisitorConfigurerJSON(b []byte, options DecodeOptions) (VisitorConfigurer, error) {
+ if isJSONNull(b) {
+ return nil, errors.New("type is required")
+ }
+
+ var env typedEnvelope
+ if err := jsonx.Unmarshal(b, &env); err != nil {
+ return nil, err
+ }
+
+ configurer := NewVisitorConfigurerByType(VisitorType(env.Type))
+ if configurer == nil {
+ return nil, fmt.Errorf("unknown visitor type: %s", env.Type)
+ }
+ if err := decodeJSONWithOptions(b, configurer, options); err != nil {
+ return nil, fmt.Errorf("unmarshal VisitorConfig error: %v", err)
+ }
+
+ if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) {
+ plugin, err := DecodeVisitorPluginOptionsJSON(env.Plugin, options)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal visitor plugin error: %v", err)
+ }
+ configurer.GetBaseConfig().Plugin = plugin
+ }
+ return configurer, nil
+}
+
+func DecodeClientPluginOptionsJSON(b []byte, options DecodeOptions) (TypedClientPluginOptions, error) {
+ if isJSONNull(b) {
+ return TypedClientPluginOptions{}, nil
+ }
+
+ var env typedEnvelope
+ if err := jsonx.Unmarshal(b, &env); err != nil {
+ return TypedClientPluginOptions{}, err
+ }
+ if env.Type == "" {
+ return TypedClientPluginOptions{}, errors.New("plugin type is empty")
+ }
+
+ v, ok := clientPluginOptionsTypeMap[env.Type]
+ if !ok {
+ return TypedClientPluginOptions{}, fmt.Errorf("unknown plugin type: %s", env.Type)
+ }
+ optionsStruct := reflect.New(v).Interface().(ClientPluginOptions)
+ if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil {
+ return TypedClientPluginOptions{}, fmt.Errorf("unmarshal ClientPluginOptions error: %v", err)
+ }
+ return TypedClientPluginOptions{
+ Type: env.Type,
+ ClientPluginOptions: optionsStruct,
+ }, nil
+}
+
+func DecodeVisitorPluginOptionsJSON(b []byte, options DecodeOptions) (TypedVisitorPluginOptions, error) {
+ if isJSONNull(b) {
+ return TypedVisitorPluginOptions{}, nil
+ }
+
+ var env typedEnvelope
+ if err := jsonx.Unmarshal(b, &env); err != nil {
+ return TypedVisitorPluginOptions{}, err
+ }
+ if env.Type == "" {
+ return TypedVisitorPluginOptions{}, errors.New("visitor plugin type is empty")
+ }
+
+ v, ok := visitorPluginOptionsTypeMap[env.Type]
+ if !ok {
+ return TypedVisitorPluginOptions{}, fmt.Errorf("unknown visitor plugin type: %s", env.Type)
+ }
+ optionsStruct := reflect.New(v).Interface().(VisitorPluginOptions)
+ if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil {
+ return TypedVisitorPluginOptions{}, fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err)
+ }
+ return TypedVisitorPluginOptions{
+ Type: env.Type,
+ VisitorPluginOptions: optionsStruct,
+ }, nil
+}
+
+func DecodeClientConfigJSON(b []byte, options DecodeOptions) (ClientConfig, error) {
+ type rawClientConfig struct {
+ ClientCommonConfig
+ Proxies []jsonx.RawMessage `json:"proxies,omitempty"`
+ Visitors []jsonx.RawMessage `json:"visitors,omitempty"`
+ }
+
+ raw := rawClientConfig{}
+ if err := decodeJSONWithOptions(b, &raw, options); err != nil {
+ return ClientConfig{}, err
+ }
+
+ cfg := ClientConfig{
+ ClientCommonConfig: raw.ClientCommonConfig,
+ Proxies: make([]TypedProxyConfig, 0, len(raw.Proxies)),
+ Visitors: make([]TypedVisitorConfig, 0, len(raw.Visitors)),
+ }
+
+ for i, proxyData := range raw.Proxies {
+ proxyCfg, err := DecodeProxyConfigurerJSON(proxyData, options)
+ if err != nil {
+ return ClientConfig{}, fmt.Errorf("decode proxy at index %d: %w", i, err)
+ }
+ cfg.Proxies = append(cfg.Proxies, TypedProxyConfig{
+ Type: proxyCfg.GetBaseConfig().Type,
+ ProxyConfigurer: proxyCfg,
+ })
+ }
+
+ for i, visitorData := range raw.Visitors {
+ visitorCfg, err := DecodeVisitorConfigurerJSON(visitorData, options)
+ if err != nil {
+ return ClientConfig{}, fmt.Errorf("decode visitor at index %d: %w", i, err)
+ }
+ cfg.Visitors = append(cfg.Visitors, TypedVisitorConfig{
+ Type: visitorCfg.GetBaseConfig().Type,
+ VisitorConfigurer: visitorCfg,
+ })
+ }
+
+ return cfg, nil
+}
diff --git a/pkg/config/v1/decode_test.go b/pkg/config/v1/decode_test.go
new file mode 100644
index 00000000..cba433ad
--- /dev/null
+++ b/pkg/config/v1/decode_test.go
@@ -0,0 +1,86 @@
+// 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
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDecodeProxyConfigurerJSON_StrictPluginUnknownFields(t *testing.T) {
+ require := require.New(t)
+
+ data := []byte(`{
+ "name":"p1",
+ "type":"tcp",
+ "localPort":10080,
+ "plugin":{
+ "type":"http2https",
+ "localAddr":"127.0.0.1:8080",
+ "unknownInPlugin":"value"
+ }
+ }`)
+
+ _, err := DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false})
+ require.NoError(err)
+
+ _, err = DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true})
+ require.ErrorContains(err, "unknownInPlugin")
+}
+
+func TestDecodeVisitorConfigurerJSON_StrictPluginUnknownFields(t *testing.T) {
+ require := require.New(t)
+
+ data := []byte(`{
+ "name":"v1",
+ "type":"stcp",
+ "serverName":"server",
+ "bindPort":10081,
+ "plugin":{
+ "type":"virtual_net",
+ "destinationIP":"10.0.0.1",
+ "unknownInPlugin":"value"
+ }
+ }`)
+
+ _, err := DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false})
+ require.NoError(err)
+
+ _, err = DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true})
+ require.ErrorContains(err, "unknownInPlugin")
+}
+
+func TestDecodeClientConfigJSON_StrictUnknownProxyField(t *testing.T) {
+ require := require.New(t)
+
+ data := []byte(`{
+ "serverPort":7000,
+ "proxies":[
+ {
+ "name":"p1",
+ "type":"tcp",
+ "localPort":10080,
+ "unknownField":"value"
+ }
+ ]
+ }`)
+
+ _, err := DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: false})
+ require.NoError(err)
+
+ _, err = DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: true})
+ require.ErrorContains(err, "unknownField")
+}
diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go
index 1bbe5ac3..e7d1a6b7 100644
--- a/pkg/config/v1/proxy.go
+++ b/pkg/config/v1/proxy.go
@@ -15,16 +15,13 @@
package v1
import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
+ "maps"
"reflect"
-
- "github.com/samber/lo"
+ "slices"
"github.com/fatedier/frp/pkg/config/types"
"github.com/fatedier/frp/pkg/msg"
+ "github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -102,11 +99,23 @@ type HealthCheckConfig struct {
HTTPHeaders []HTTPHeader `json:"httpHeaders,omitempty"`
}
+func (c HealthCheckConfig) Clone() HealthCheckConfig {
+ out := c
+ out.HTTPHeaders = slices.Clone(c.HTTPHeaders)
+ return out
+}
+
type DomainConfig struct {
CustomDomains []string `json:"customDomains,omitempty"`
SubDomain string `json:"subdomain,omitempty"`
}
+func (c DomainConfig) Clone() DomainConfig {
+ out := c
+ out.CustomDomains = slices.Clone(c.CustomDomains)
+ return out
+}
+
type ProxyBaseConfig struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -122,12 +131,27 @@ type ProxyBaseConfig struct {
ProxyBackend
}
+func (c ProxyBaseConfig) Clone() ProxyBaseConfig {
+ out := c
+ out.Enabled = util.ClonePtr(c.Enabled)
+ out.Annotations = maps.Clone(c.Annotations)
+ out.Metadatas = maps.Clone(c.Metadatas)
+ out.HealthCheck = c.HealthCheck.Clone()
+ out.ProxyBackend = c.ProxyBackend.Clone()
+ return out
+}
+
+func (c ProxyBackend) Clone() ProxyBackend {
+ out := c
+ out.Plugin = c.Plugin.Clone()
+ return out
+}
+
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)
@@ -175,40 +199,24 @@ type TypedProxyConfig struct {
}
func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error {
- if len(b) == 4 && string(b) == "null" {
- return errors.New("type is required")
- }
-
- typeStruct := struct {
- Type string `json:"type"`
- }{}
- if err := json.Unmarshal(b, &typeStruct); err != nil {
+ configurer, err := DecodeProxyConfigurerJSON(b, DecodeOptions{})
+ if err != nil {
return err
}
- c.Type = typeStruct.Type
- configurer := NewProxyConfigurerByType(ProxyType(typeStruct.Type))
- if configurer == nil {
- return fmt.Errorf("unknown proxy type: %s", typeStruct.Type)
- }
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if DisallowUnknownFields {
- decoder.DisallowUnknownFields()
- }
- if err := decoder.Decode(configurer); err != nil {
- return fmt.Errorf("unmarshal ProxyConfig error: %v", err)
- }
+ c.Type = configurer.GetBaseConfig().Type
c.ProxyConfigurer = configurer
return nil
}
func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {
- return json.Marshal(c.ProxyConfigurer)
+ return jsonx.Marshal(c.ProxyConfigurer)
}
type ProxyConfigurer interface {
- Complete(namePrefix string)
+ Complete()
GetBaseConfig() *ProxyBaseConfig
+ Clone() ProxyConfigurer
// MarshalToMsg marshals this config into a msg.NewProxy message. This
// function will be called on the frpc side.
MarshalToMsg(*msg.NewProxy)
@@ -231,14 +239,14 @@ const (
)
var proxyConfigTypeMap = map[ProxyType]reflect.Type{
- ProxyTypeTCP: reflect.TypeOf(TCPProxyConfig{}),
- ProxyTypeUDP: reflect.TypeOf(UDPProxyConfig{}),
- ProxyTypeHTTP: reflect.TypeOf(HTTPProxyConfig{}),
- ProxyTypeHTTPS: reflect.TypeOf(HTTPSProxyConfig{}),
- ProxyTypeTCPMUX: reflect.TypeOf(TCPMuxProxyConfig{}),
- ProxyTypeSTCP: reflect.TypeOf(STCPProxyConfig{}),
- ProxyTypeXTCP: reflect.TypeOf(XTCPProxyConfig{}),
- ProxyTypeSUDP: reflect.TypeOf(SUDPProxyConfig{}),
+ ProxyTypeTCP: reflect.TypeFor[TCPProxyConfig](),
+ ProxyTypeUDP: reflect.TypeFor[UDPProxyConfig](),
+ ProxyTypeHTTP: reflect.TypeFor[HTTPProxyConfig](),
+ ProxyTypeHTTPS: reflect.TypeFor[HTTPSProxyConfig](),
+ ProxyTypeTCPMUX: reflect.TypeFor[TCPMuxProxyConfig](),
+ ProxyTypeSTCP: reflect.TypeFor[STCPProxyConfig](),
+ ProxyTypeXTCP: reflect.TypeFor[XTCPProxyConfig](),
+ ProxyTypeSUDP: reflect.TypeFor[SUDPProxyConfig](),
}
func NewProxyConfigurerByType(proxyType ProxyType) ProxyConfigurer {
@@ -271,6 +279,12 @@ func (c *TCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RemotePort = m.RemotePort
}
+func (c *TCPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &UDPProxyConfig{}
type UDPProxyConfig struct {
@@ -291,6 +305,12 @@ func (c *UDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RemotePort = m.RemotePort
}
+func (c *UDPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &HTTPProxyConfig{}
type HTTPProxyConfig struct {
@@ -334,6 +354,16 @@ func (c *HTTPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RouteByHTTPUser = m.RouteByHTTPUser
}
+func (c *HTTPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.DomainConfig = c.DomainConfig.Clone()
+ out.Locations = slices.Clone(c.Locations)
+ out.RequestHeaders = c.RequestHeaders.Clone()
+ out.ResponseHeaders = c.ResponseHeaders.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &HTTPSProxyConfig{}
type HTTPSProxyConfig struct {
@@ -355,6 +385,13 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.SubDomain = m.SubDomain
}
+func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.DomainConfig = c.DomainConfig.Clone()
+ return &out
+}
+
type TCPMultiplexerType string
const (
@@ -395,6 +432,13 @@ func (c *TCPMuxProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RouteByHTTPUser = m.RouteByHTTPUser
}
+func (c *TCPMuxProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.DomainConfig = c.DomainConfig.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &STCPProxyConfig{}
type STCPProxyConfig struct {
@@ -418,6 +462,13 @@ func (c *STCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.AllowUsers = m.AllowUsers
}
+func (c *STCPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.AllowUsers = slices.Clone(c.AllowUsers)
+ return &out
+}
+
var _ ProxyConfigurer = &XTCPProxyConfig{}
type XTCPProxyConfig struct {
@@ -444,6 +495,14 @@ func (c *XTCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.AllowUsers = m.AllowUsers
}
+func (c *XTCPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.AllowUsers = slices.Clone(c.AllowUsers)
+ out.NatTraversal = c.NatTraversal.Clone()
+ return &out
+}
+
var _ ProxyConfigurer = &SUDPProxyConfig{}
type SUDPProxyConfig struct {
@@ -466,3 +525,10 @@ func (c *SUDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.Secretkey = m.Sk
c.AllowUsers = m.AllowUsers
}
+
+func (c *SUDPProxyConfig) Clone() ProxyConfigurer {
+ out := *c
+ out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
+ out.AllowUsers = slices.Clone(c.AllowUsers)
+ return &out
+}
diff --git a/pkg/config/v1/proxy_plugin.go b/pkg/config/v1/proxy_plugin.go
index 128ccae6..0d6ca3d0 100644
--- a/pkg/config/v1/proxy_plugin.go
+++ b/pkg/config/v1/proxy_plugin.go
@@ -15,14 +15,11 @@
package v1
import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
"reflect"
"github.com/samber/lo"
+ "github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -40,20 +37,21 @@ const (
)
var clientPluginOptionsTypeMap = map[string]reflect.Type{
- PluginHTTP2HTTPS: reflect.TypeOf(HTTP2HTTPSPluginOptions{}),
- PluginHTTPProxy: reflect.TypeOf(HTTPProxyPluginOptions{}),
- PluginHTTPS2HTTP: reflect.TypeOf(HTTPS2HTTPPluginOptions{}),
- PluginHTTPS2HTTPS: reflect.TypeOf(HTTPS2HTTPSPluginOptions{}),
- PluginHTTP2HTTP: reflect.TypeOf(HTTP2HTTPPluginOptions{}),
- PluginSocks5: reflect.TypeOf(Socks5PluginOptions{}),
- PluginStaticFile: reflect.TypeOf(StaticFilePluginOptions{}),
- PluginUnixDomainSocket: reflect.TypeOf(UnixDomainSocketPluginOptions{}),
- PluginTLS2Raw: reflect.TypeOf(TLS2RawPluginOptions{}),
- PluginVirtualNet: reflect.TypeOf(VirtualNetPluginOptions{}),
+ PluginHTTP2HTTPS: reflect.TypeFor[HTTP2HTTPSPluginOptions](),
+ PluginHTTPProxy: reflect.TypeFor[HTTPProxyPluginOptions](),
+ PluginHTTPS2HTTP: reflect.TypeFor[HTTPS2HTTPPluginOptions](),
+ PluginHTTPS2HTTPS: reflect.TypeFor[HTTPS2HTTPSPluginOptions](),
+ PluginHTTP2HTTP: reflect.TypeFor[HTTP2HTTPPluginOptions](),
+ PluginSocks5: reflect.TypeFor[Socks5PluginOptions](),
+ PluginStaticFile: reflect.TypeFor[StaticFilePluginOptions](),
+ PluginUnixDomainSocket: reflect.TypeFor[UnixDomainSocketPluginOptions](),
+ PluginTLS2Raw: reflect.TypeFor[TLS2RawPluginOptions](),
+ PluginVirtualNet: reflect.TypeFor[VirtualNetPluginOptions](),
}
type ClientPluginOptions interface {
Complete()
+ Clone() ClientPluginOptions
}
type TypedClientPluginOptions struct {
@@ -61,43 +59,25 @@ type TypedClientPluginOptions struct {
ClientPluginOptions
}
-func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
- if len(b) == 4 && string(b) == "null" {
- return nil
+func (c TypedClientPluginOptions) Clone() TypedClientPluginOptions {
+ out := c
+ if c.ClientPluginOptions != nil {
+ out.ClientPluginOptions = c.ClientPluginOptions.Clone()
}
+ return out
+}
- typeStruct := struct {
- Type string `json:"type"`
- }{}
- if err := json.Unmarshal(b, &typeStruct); err != nil {
+func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
+ decoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{})
+ if err != nil {
return err
}
-
- c.Type = typeStruct.Type
- if c.Type == "" {
- return errors.New("plugin type is empty")
- }
-
- v, ok := clientPluginOptionsTypeMap[typeStruct.Type]
- if !ok {
- return fmt.Errorf("unknown plugin type: %s", typeStruct.Type)
- }
- options := reflect.New(v).Interface().(ClientPluginOptions)
-
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if DisallowUnknownFields {
- decoder.DisallowUnknownFields()
- }
-
- if err := decoder.Decode(options); err != nil {
- return fmt.Errorf("unmarshal ClientPluginOptions error: %v", err)
- }
- c.ClientPluginOptions = options
+ *c = decoded
return nil
}
func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
- return json.Marshal(c.ClientPluginOptions)
+ return jsonx.Marshal(c.ClientPluginOptions)
}
type HTTP2HTTPSPluginOptions struct {
@@ -109,6 +89,15 @@ type HTTP2HTTPSPluginOptions struct {
func (o *HTTP2HTTPSPluginOptions) Complete() {}
+func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.RequestHeaders = o.RequestHeaders.Clone()
+ return &out
+}
+
type HTTPProxyPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPUser string `json:"httpUser,omitempty"`
@@ -117,6 +106,14 @@ type HTTPProxyPluginOptions struct {
func (o *HTTPProxyPluginOptions) Complete() {}
+func (o *HTTPProxyPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type HTTPS2HTTPPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -131,6 +128,16 @@ func (o *HTTPS2HTTPPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
}
+func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.RequestHeaders = o.RequestHeaders.Clone()
+ out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
+ return &out
+}
+
type HTTPS2HTTPSPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -145,6 +152,16 @@ func (o *HTTPS2HTTPSPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
}
+func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.RequestHeaders = o.RequestHeaders.Clone()
+ out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
+ return &out
+}
+
type HTTP2HTTPPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -154,6 +171,15 @@ type HTTP2HTTPPluginOptions struct {
func (o *HTTP2HTTPPluginOptions) Complete() {}
+func (o *HTTP2HTTPPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ out.RequestHeaders = o.RequestHeaders.Clone()
+ return &out
+}
+
type Socks5PluginOptions struct {
Type string `json:"type,omitempty"`
Username string `json:"username,omitempty"`
@@ -162,6 +188,14 @@ type Socks5PluginOptions struct {
func (o *Socks5PluginOptions) Complete() {}
+func (o *Socks5PluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type StaticFilePluginOptions struct {
Type string `json:"type,omitempty"`
LocalPath string `json:"localPath,omitempty"`
@@ -172,6 +206,14 @@ type StaticFilePluginOptions struct {
func (o *StaticFilePluginOptions) Complete() {}
+func (o *StaticFilePluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type UnixDomainSocketPluginOptions struct {
Type string `json:"type,omitempty"`
UnixPath string `json:"unixPath,omitempty"`
@@ -179,6 +221,14 @@ type UnixDomainSocketPluginOptions struct {
func (o *UnixDomainSocketPluginOptions) Complete() {}
+func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type TLS2RawPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -188,8 +238,24 @@ type TLS2RawPluginOptions struct {
func (o *TLS2RawPluginOptions) Complete() {}
+func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
+
type VirtualNetPluginOptions struct {
Type string `json:"type,omitempty"`
}
func (o *VirtualNetPluginOptions) Complete() {}
+
+func (o *VirtualNetPluginOptions) Clone() ClientPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
diff --git a/pkg/config/v1/store.go b/pkg/config/v1/store.go
new file mode 100644
index 00000000..7f992c3b
--- /dev/null
+++ b/pkg/config/v1/store.go
@@ -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 != ""
+}
diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go
index eb4a0253..c90d525d 100644
--- a/pkg/config/v1/validation/client.go
+++ b/pkg/config/v1/validation/client.go
@@ -88,6 +88,11 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
errs = AppendError(errs, err)
}
+ if c.Method == v1.AuthMethodOIDC && c.OIDC.TokenSource == nil {
+ if err := ValidateOIDCClientCredentialsConfig(&c.OIDC); err != nil {
+ errs = AppendError(errs, err)
+ }
+ }
return nil, errs
}
diff --git a/pkg/config/v1/validation/oidc.go b/pkg/config/v1/validation/oidc.go
new file mode 100644
index 00000000..c905e8e5
--- /dev/null
+++ b/pkg/config/v1/validation/oidc.go
@@ -0,0 +1,57 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+import (
+ "errors"
+ "net/url"
+ "strings"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error {
+ var errs []string
+
+ if c.ClientID == "" {
+ errs = append(errs, "auth.oidc.clientID is required")
+ }
+
+ if c.TokenEndpointURL == "" {
+ errs = append(errs, "auth.oidc.tokenEndpointURL is required")
+ } else {
+ tokenURL, err := url.Parse(c.TokenEndpointURL)
+ if err != nil || !tokenURL.IsAbs() || tokenURL.Host == "" {
+ errs = append(errs, "auth.oidc.tokenEndpointURL must be an absolute http or https URL")
+ } else if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
+ errs = append(errs, "auth.oidc.tokenEndpointURL must use http or https")
+ }
+ }
+
+ if _, ok := c.AdditionalEndpointParams["scope"]; ok {
+ errs = append(errs, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
+ }
+
+ if c.Audience != "" {
+ if _, ok := c.AdditionalEndpointParams["audience"]; ok {
+ errs = append(errs, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
+ }
+ }
+
+ if len(errs) == 0 {
+ return nil
+ }
+ return errors.New(strings.Join(errs, "; "))
+}
diff --git a/pkg/config/v1/validation/oidc_test.go b/pkg/config/v1/validation/oidc_test.go
new file mode 100644
index 00000000..bc21da6e
--- /dev/null
+++ b/pkg/config/v1/validation/oidc_test.go
@@ -0,0 +1,78 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package validation
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func TestValidateOIDCClientCredentialsConfig(t *testing.T) {
+ tokenServer := httptest.NewServer(http.NotFoundHandler())
+ defer tokenServer.Close()
+
+ t.Run("valid", func(t *testing.T) {
+ require.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ AdditionalEndpointParams: map[string]string{
+ "resource": "api",
+ },
+ }))
+ })
+
+ t.Run("invalid token endpoint url", func(t *testing.T) {
+ err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: "://bad",
+ })
+ require.ErrorContains(t, err, "auth.oidc.tokenEndpointURL")
+ })
+
+ t.Run("missing client id", func(t *testing.T) {
+ err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ TokenEndpointURL: tokenServer.URL,
+ })
+ require.ErrorContains(t, err, "auth.oidc.clientID is required")
+ })
+
+ t.Run("scope endpoint param is not allowed", func(t *testing.T) {
+ err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ AdditionalEndpointParams: map[string]string{
+ "scope": "email",
+ },
+ })
+ require.ErrorContains(t, err, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
+ })
+
+ t.Run("audience conflict", func(t *testing.T) {
+ err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
+ ClientID: "test-client",
+ TokenEndpointURL: tokenServer.URL,
+ Audience: "api",
+ AdditionalEndpointParams: map[string]string{
+ "audience": "override",
+ },
+ })
+ require.ErrorContains(t, err, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
+ })
+}
diff --git a/pkg/config/v1/validation/proxy.go b/pkg/config/v1/validation/proxy.go
index d8e3d01e..744620f0 100644
--- a/pkg/config/v1/validation/proxy.go
+++ b/pkg/config/v1/validation/proxy.go
@@ -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)
}
}
diff --git a/pkg/config/v1/visitor.go b/pkg/config/v1/visitor.go
index 5017f57d..d1035d4f 100644
--- a/pkg/config/v1/visitor.go
+++ b/pkg/config/v1/visitor.go
@@ -15,14 +15,9 @@
package v1
import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
"reflect"
- "github.com/samber/lo"
-
+ "github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -52,31 +47,27 @@ type VisitorBaseConfig struct {
Plugin TypedVisitorPluginOptions `json:"plugin,omitempty"`
}
+func (c VisitorBaseConfig) Clone() VisitorBaseConfig {
+ out := c
+ out.Enabled = util.ClonePtr(c.Enabled)
+ out.Plugin = c.Plugin.Clone()
+ return out
+}
+
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
+ Clone() VisitorConfigurer
}
type VisitorType string
@@ -88,9 +79,9 @@ const (
)
var visitorConfigTypeMap = map[VisitorType]reflect.Type{
- VisitorTypeSTCP: reflect.TypeOf(STCPVisitorConfig{}),
- VisitorTypeXTCP: reflect.TypeOf(XTCPVisitorConfig{}),
- VisitorTypeSUDP: reflect.TypeOf(SUDPVisitorConfig{}),
+ VisitorTypeSTCP: reflect.TypeFor[STCPVisitorConfig](),
+ VisitorTypeXTCP: reflect.TypeFor[XTCPVisitorConfig](),
+ VisitorTypeSUDP: reflect.TypeFor[SUDPVisitorConfig](),
}
type TypedVisitorConfig struct {
@@ -99,35 +90,18 @@ type TypedVisitorConfig struct {
}
func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error {
- if len(b) == 4 && string(b) == "null" {
- return errors.New("type is required")
- }
-
- typeStruct := struct {
- Type string `json:"type"`
- }{}
- if err := json.Unmarshal(b, &typeStruct); err != nil {
+ configurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{})
+ if err != nil {
return err
}
- c.Type = typeStruct.Type
- configurer := NewVisitorConfigurerByType(VisitorType(typeStruct.Type))
- if configurer == nil {
- return fmt.Errorf("unknown visitor type: %s", typeStruct.Type)
- }
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if DisallowUnknownFields {
- decoder.DisallowUnknownFields()
- }
- if err := decoder.Decode(configurer); err != nil {
- return fmt.Errorf("unmarshal VisitorConfig error: %v", err)
- }
+ c.Type = configurer.GetBaseConfig().Type
c.VisitorConfigurer = configurer
return nil
}
func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) {
- return json.Marshal(c.VisitorConfigurer)
+ return jsonx.Marshal(c.VisitorConfigurer)
}
func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer {
@@ -146,12 +120,24 @@ type STCPVisitorConfig struct {
VisitorBaseConfig
}
+func (c *STCPVisitorConfig) Clone() VisitorConfigurer {
+ out := *c
+ out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
+ return &out
+}
+
var _ VisitorConfigurer = &SUDPVisitorConfig{}
type SUDPVisitorConfig struct {
VisitorBaseConfig
}
+func (c *SUDPVisitorConfig) Clone() VisitorConfigurer {
+ out := *c
+ out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
+ return &out
+}
+
var _ VisitorConfigurer = &XTCPVisitorConfig{}
type XTCPVisitorConfig struct {
@@ -168,15 +154,18 @@ 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
- }
+}
+
+func (c *XTCPVisitorConfig) Clone() VisitorConfigurer {
+ out := *c
+ out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
+ out.NatTraversal = c.NatTraversal.Clone()
+ return &out
}
diff --git a/pkg/config/v1/visitor_plugin.go b/pkg/config/v1/visitor_plugin.go
index 5a4909bd..6541e3de 100644
--- a/pkg/config/v1/visitor_plugin.go
+++ b/pkg/config/v1/visitor_plugin.go
@@ -15,11 +15,9 @@
package v1
import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
"reflect"
+
+ "github.com/fatedier/frp/pkg/util/jsonx"
)
const (
@@ -27,11 +25,12 @@ const (
)
var visitorPluginOptionsTypeMap = map[string]reflect.Type{
- VisitorPluginVirtualNet: reflect.TypeOf(VirtualNetVisitorPluginOptions{}),
+ VisitorPluginVirtualNet: reflect.TypeFor[VirtualNetVisitorPluginOptions](),
}
type VisitorPluginOptions interface {
Complete()
+ Clone() VisitorPluginOptions
}
type TypedVisitorPluginOptions struct {
@@ -39,43 +38,25 @@ type TypedVisitorPluginOptions struct {
VisitorPluginOptions
}
-func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
- if len(b) == 4 && string(b) == "null" {
- return nil
+func (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions {
+ out := c
+ if c.VisitorPluginOptions != nil {
+ out.VisitorPluginOptions = c.VisitorPluginOptions.Clone()
}
+ return out
+}
- typeStruct := struct {
- Type string `json:"type"`
- }{}
- if err := json.Unmarshal(b, &typeStruct); err != nil {
+func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
+ decoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{})
+ if err != nil {
return err
}
-
- c.Type = typeStruct.Type
- if c.Type == "" {
- return errors.New("visitor plugin type is empty")
- }
-
- v, ok := visitorPluginOptionsTypeMap[typeStruct.Type]
- if !ok {
- return fmt.Errorf("unknown visitor plugin type: %s", typeStruct.Type)
- }
- options := reflect.New(v).Interface().(VisitorPluginOptions)
-
- decoder := json.NewDecoder(bytes.NewBuffer(b))
- if DisallowUnknownFields {
- decoder.DisallowUnknownFields()
- }
-
- if err := decoder.Decode(options); err != nil {
- return fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err)
- }
- c.VisitorPluginOptions = options
+ *c = decoded
return nil
}
func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) {
- return json.Marshal(c.VisitorPluginOptions)
+ return jsonx.Marshal(c.VisitorPluginOptions)
}
type VirtualNetVisitorPluginOptions struct {
@@ -84,3 +65,11 @@ type VirtualNetVisitorPluginOptions struct {
}
func (o *VirtualNetVisitorPluginOptions) Complete() {}
+
+func (o *VirtualNetVisitorPluginOptions) Clone() VisitorPluginOptions {
+ if o == nil {
+ return nil
+ }
+ out := *o
+ return &out
+}
diff --git a/pkg/metrics/mem/server.go b/pkg/metrics/mem/server.go
index 677788d3..add90a5a 100644
--- a/pkg/metrics/mem/server.go
+++ b/pkg/metrics/mem/server.go
@@ -143,7 +143,6 @@ func (m *serverMetrics) OpenConnection(name string, _ string) {
proxyStats, ok := m.info.ProxyStatistics[name]
if ok {
proxyStats.CurConns.Inc(1)
- m.info.ProxyStatistics[name] = proxyStats
}
}
@@ -155,7 +154,6 @@ func (m *serverMetrics) CloseConnection(name string, _ string) {
proxyStats, ok := m.info.ProxyStatistics[name]
if ok {
proxyStats.CurConns.Dec(1)
- m.info.ProxyStatistics[name] = proxyStats
}
}
@@ -168,7 +166,6 @@ func (m *serverMetrics) AddTrafficIn(name string, _ string, trafficBytes int64)
proxyStats, ok := m.info.ProxyStatistics[name]
if ok {
proxyStats.TrafficIn.Inc(trafficBytes)
- m.info.ProxyStatistics[name] = proxyStats
}
}
@@ -181,7 +178,6 @@ func (m *serverMetrics) AddTrafficOut(name string, _ string, trafficBytes int64)
proxyStats, ok := m.info.ProxyStatistics[name]
if ok {
proxyStats.TrafficOut.Inc(trafficBytes)
- m.info.ProxyStatistics[name] = proxyStats
}
}
@@ -203,6 +199,25 @@ func (m *serverMetrics) GetServer() *ServerStats {
return s
}
+func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
+ ps := &ProxyStats{
+ Name: name,
+ Type: proxyStats.ProxyType,
+ User: proxyStats.User,
+ ClientID: proxyStats.ClientID,
+ TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
+ TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
+ CurConns: int64(proxyStats.CurConns.Count()),
+ }
+ if !proxyStats.LastStartTime.IsZero() {
+ ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
+ }
+ if !proxyStats.LastCloseTime.IsZero() {
+ ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
+ }
+ return ps
+}
+
func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
res := make([]*ProxyStats, 0)
m.mu.Lock()
@@ -212,23 +227,7 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
if proxyStats.ProxyType != proxyType {
continue
}
-
- ps := &ProxyStats{
- Name: name,
- Type: proxyStats.ProxyType,
- User: proxyStats.User,
- ClientID: proxyStats.ClientID,
- TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
- TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
- CurConns: int64(proxyStats.CurConns.Count()),
- }
- if !proxyStats.LastStartTime.IsZero() {
- ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
- }
- if !proxyStats.LastCloseTime.IsZero() {
- ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
- }
- res = append(res, ps)
+ res = append(res, toProxyStats(name, proxyStats))
}
return res
}
@@ -237,31 +236,9 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
m.mu.Lock()
defer m.mu.Unlock()
- for name, proxyStats := range m.info.ProxyStatistics {
- if proxyStats.ProxyType != proxyType {
- continue
- }
-
- if name != proxyName {
- continue
- }
-
- res = &ProxyStats{
- Name: name,
- Type: proxyStats.ProxyType,
- User: proxyStats.User,
- ClientID: proxyStats.ClientID,
- TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
- TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
- CurConns: int64(proxyStats.CurConns.Count()),
- }
- if !proxyStats.LastStartTime.IsZero() {
- res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
- }
- if !proxyStats.LastCloseTime.IsZero() {
- res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
- }
- break
+ proxyStats, ok := m.info.ProxyStatistics[proxyName]
+ if ok && proxyStats.ProxyType == proxyType {
+ res = toProxyStats(proxyName, proxyStats)
}
return
}
@@ -272,21 +249,7 @@ func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {
proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok {
- res = &ProxyStats{
- Name: proxyName,
- Type: proxyStats.ProxyType,
- User: proxyStats.User,
- ClientID: proxyStats.ClientID,
- TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
- TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
- CurConns: int64(proxyStats.CurConns.Count()),
- }
- if !proxyStats.LastStartTime.IsZero() {
- res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
- }
- if !proxyStats.LastCloseTime.IsZero() {
- res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
- }
+ res = toProxyStats(proxyName, proxyStats)
}
return
}
diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go
index 7b36c2aa..e8bcbc35 100644
--- a/pkg/msg/msg.go
+++ b/pkg/msg/msg.go
@@ -61,7 +61,7 @@ var msgTypeMap = map[byte]any{
TypeNatHoleReport: NatHoleReport{},
}
-var TypeNameNatHoleResp = reflect.TypeOf(&NatHoleResp{}).Elem().Name()
+var TypeNameNatHoleResp = reflect.TypeFor[NatHoleResp]().Name()
type ClientSpec struct {
// Due to the support of VirtualClient, frps needs to know the client type in order to
@@ -184,7 +184,7 @@ type Pong struct {
}
type UDPPacket struct {
- Content string `json:"c,omitempty"`
+ Content []byte `json:"c,omitempty"`
LocalAddr *net.UDPAddr `json:"l,omitempty"`
RemoteAddr *net.UDPAddr `json:"r,omitempty"`
}
diff --git a/pkg/naming/names.go b/pkg/naming/names.go
new file mode 100644
index 00000000..3ca4dd9c
--- /dev/null
+++ b/pkg/naming/names.go
@@ -0,0 +1,32 @@
+package naming
+
+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
+ }
+ if trimmed, ok := strings.CutPrefix(name, user+"."); ok {
+ return trimmed
+ }
+ 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)
+}
diff --git a/pkg/naming/names_test.go b/pkg/naming/names_test.go
new file mode 100644
index 00000000..3792af42
--- /dev/null
+++ b/pkg/naming/names_test.go
@@ -0,0 +1,27 @@
+package naming
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+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"))
+}
diff --git a/pkg/nathole/analysis.go b/pkg/nathole/analysis.go
index 6be0be55..ed9eb6ce 100644
--- a/pkg/nathole/analysis.go
+++ b/pkg/nathole/analysis.go
@@ -151,7 +151,7 @@ func getBehaviorScoresByMode(mode int, defaultScore int) []*BehaviorScore {
func getBehaviorScoresByMode2(mode int, senderScore, receiverScore int) []*BehaviorScore {
behaviors := getBehaviorByMode(mode)
scores := make([]*BehaviorScore, 0, len(behaviors))
- for i := 0; i < len(behaviors); i++ {
+ for i := range behaviors {
score := receiverScore
if behaviors[i].A.Role == DetectRoleSender {
score = senderScore
diff --git a/pkg/nathole/classify.go b/pkg/nathole/classify.go
index 5abb0e23..6e00a583 100644
--- a/pkg/nathole/classify.go
+++ b/pkg/nathole/classify.go
@@ -70,12 +70,8 @@ func ClassifyNATFeature(addresses []string, localIPs []string) (*NatFeature, err
continue
}
- if portNum > portMax {
- portMax = portNum
- }
- if portNum < portMin {
- portMin = portNum
- }
+ portMax = max(portMax, portNum)
+ portMin = min(portMin, portNum)
if baseIP != ip {
ipChanged = true
}
diff --git a/pkg/nathole/controller.go b/pkg/nathole/controller.go
index c08c81c9..2562bfd2 100644
--- a/pkg/nathole/controller.go
+++ b/pkg/nathole/controller.go
@@ -152,7 +152,9 @@ func (c *Controller) GenSid() string {
func (c *Controller) HandleVisitor(m *msg.NatHoleVisitor, transporter transport.MessageTransporter, visitorUser string) {
if m.PreCheck {
+ c.mu.RLock()
cfg, ok := c.clientCfgs[m.ProxyName]
+ c.mu.RUnlock()
if !ok {
_ = transporter.Send(c.GenNatHoleResponse(m.TransactionID, nil, fmt.Sprintf("xtcp server for [%s] doesn't exist", m.ProxyName)))
return
@@ -375,7 +377,7 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if !isLast {
return nil
}
- var ports []msg.PortsRange
+ ports := make([]msg.PortsRange, 0, 1)
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil
diff --git a/pkg/nathole/nathole.go b/pkg/nathole/nathole.go
index 72522fac..bea3d371 100644
--- a/pkg/nathole/nathole.go
+++ b/pkg/nathole/nathole.go
@@ -298,11 +298,13 @@ func waitDetectMessage(
n, raddr, err := conn.ReadFromUDP(buf)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
+ pool.PutBuf(buf)
return nil, err
}
xl.Debugf("get udp message local %s, from %s", conn.LocalAddr(), raddr)
var m msg.NatHoleSid
if err := DecodeMessageInto(buf[:n], key, &m); err != nil {
+ pool.PutBuf(buf)
xl.Warnf("decode sid message error: %v", err)
continue
}
@@ -408,7 +410,7 @@ func sendSidMessageToRandomPorts(
xl := xlog.FromContextSafe(ctx)
used := sets.New[int]()
getUnusedPort := func() int {
- for i := 0; i < 10; i++ {
+ for range 10 {
port := rand.IntN(65535-1024) + 1024
if !used.Has(port) {
used.Insert(port)
@@ -418,7 +420,7 @@ func sendSidMessageToRandomPorts(
return 0
}
- for i := 0; i < count; i++ {
+ for range count {
select {
case <-ctx.Done():
return
diff --git a/pkg/plugin/client/http2http.go b/pkg/plugin/client/http2http.go
index 889a10f6..e50a91c0 100644
--- a/pkg/plugin/client/http2http.go
+++ b/pkg/plugin/client/http2http.go
@@ -21,6 +21,7 @@ import (
stdlog "log"
"net/http"
"net/http/httputil"
+ "time"
"github.com/fatedier/golib/pool"
@@ -68,7 +69,7 @@ func NewHTTP2HTTPPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugin
p.s = &http.Server{
Handler: rp,
- ReadHeaderTimeout: 0,
+ ReadHeaderTimeout: 60 * time.Second,
}
go func() {
diff --git a/pkg/plugin/client/http2https.go b/pkg/plugin/client/http2https.go
index 538f2850..8119e095 100644
--- a/pkg/plugin/client/http2https.go
+++ b/pkg/plugin/client/http2https.go
@@ -22,6 +22,7 @@ import (
stdlog "log"
"net/http"
"net/http/httputil"
+ "time"
"github.com/fatedier/golib/pool"
@@ -77,7 +78,7 @@ func NewHTTP2HTTPSPlugin(_ PluginContext, options v1.ClientPluginOptions) (Plugi
p.s = &http.Server{
Handler: rp,
- ReadHeaderTimeout: 0,
+ ReadHeaderTimeout: 60 * time.Second,
}
go func() {
diff --git a/pkg/plugin/client/tls2raw.go b/pkg/plugin/client/tls2raw.go
index 445b6c91..ccb75149 100644
--- a/pkg/plugin/client/tls2raw.go
+++ b/pkg/plugin/client/tls2raw.go
@@ -62,11 +62,13 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
if err := tlsConn.Handshake(); err != nil {
xl.Warnf("tls handshake error: %v", err)
+ tlsConn.Close()
return
}
rawConn, err := net.Dial("tcp", p.opts.LocalAddr)
if err != nil {
xl.Warnf("dial to local addr error: %v", err)
+ tlsConn.Close()
return
}
diff --git a/pkg/plugin/client/unix_domain_socket.go b/pkg/plugin/client/unix_domain_socket.go
index 52d9c652..3046aade 100644
--- a/pkg/plugin/client/unix_domain_socket.go
+++ b/pkg/plugin/client/unix_domain_socket.go
@@ -54,10 +54,13 @@ func (uds *UnixDomainSocketPlugin) Handle(ctx context.Context, connInfo *Connect
localConn, err := net.DialUnix("unix", nil, uds.UnixAddr)
if err != nil {
xl.Warnf("dial to uds %s error: %v", uds.UnixAddr, err)
+ connInfo.Conn.Close()
return
}
if connInfo.ProxyProtocolHeader != nil {
if _, err := connInfo.ProxyProtocolHeader.WriteTo(localConn); err != nil {
+ localConn.Close()
+ connInfo.Conn.Close()
return
}
}
diff --git a/pkg/plugin/server/http.go b/pkg/plugin/server/http.go
index 196993ef..b05b909e 100644
--- a/pkg/plugin/server/http.go
+++ b/pkg/plugin/server/http.go
@@ -24,6 +24,7 @@ import (
"net/http"
"net/url"
"reflect"
+ "slices"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -64,12 +65,7 @@ func (p *httpPlugin) Name() string {
}
func (p *httpPlugin) IsSupport(op string) bool {
- for _, v := range p.options.Ops {
- if v == op {
- return true
- }
- }
- return false
+ return slices.Contains(p.options.Ops, op)
}
func (p *httpPlugin) Handle(ctx context.Context, op string, content any) (*Response, any, error) {
diff --git a/pkg/plugin/visitor/virtual_net.go b/pkg/plugin/visitor/virtual_net.go
index 8193ce03..fa8c700e 100644
--- a/pkg/plugin/visitor/virtual_net.go
+++ b/pkg/plugin/visitor/virtual_net.go
@@ -153,10 +153,7 @@ func (p *VirtualNetPlugin) run() {
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
baseDelay := 60 * time.Second
- reconnectDelay = baseDelay * time.Duration(1< 300*time.Second {
- reconnectDelay = 300 * time.Second
- }
+ reconnectDelay = min(baseDelay*time.Duration(1< 0 {
diff --git a/pkg/policy/featuregate/feature_gate.go b/pkg/policy/featuregate/feature_gate.go
index c5fd684b..f27aecf3 100644
--- a/pkg/policy/featuregate/feature_gate.go
+++ b/pkg/policy/featuregate/feature_gate.go
@@ -16,6 +16,7 @@ package featuregate
import (
"fmt"
+ "maps"
"sort"
"strings"
"sync"
@@ -92,10 +93,7 @@ type featureGate struct {
// NewFeatureGate creates a new feature gate with the default features
func NewFeatureGate() MutableFeatureGate {
- known := map[Feature]FeatureSpec{}
- for k, v := range defaultFeatures {
- known[k] = v
- }
+ known := maps.Clone(defaultFeatures)
f := &featureGate{}
f.known.Store(known)
@@ -109,14 +107,8 @@ func (f *featureGate) SetFromMap(m map[string]bool) error {
defer f.lock.Unlock()
// Copy existing state
- known := map[Feature]FeatureSpec{}
- for k, v := range f.known.Load().(map[Feature]FeatureSpec) {
- known[k] = v
- }
- enabled := map[Feature]bool{}
- for k, v := range f.enabled.Load().(map[Feature]bool) {
- enabled[k] = v
- }
+ known := maps.Clone(f.known.Load().(map[Feature]FeatureSpec))
+ enabled := maps.Clone(f.enabled.Load().(map[Feature]bool))
// Apply the new settings
for k, v := range m {
@@ -147,10 +139,7 @@ func (f *featureGate) Add(features map[Feature]FeatureSpec) error {
}
// Copy existing state
- known := map[Feature]FeatureSpec{}
- for k, v := range f.known.Load().(map[Feature]FeatureSpec) {
- known[k] = v
- }
+ known := maps.Clone(f.known.Load().(map[Feature]FeatureSpec))
// Add new features
for name, spec := range features {
@@ -171,8 +160,9 @@ func (f *featureGate) Add(features map[Feature]FeatureSpec) error {
// String returns a string containing all enabled feature gates, formatted as "key1=value1,key2=value2,..."
func (f *featureGate) String() string {
- pairs := []string{}
- for k, v := range f.enabled.Load().(map[Feature]bool) {
+ enabled := f.enabled.Load().(map[Feature]bool)
+ pairs := make([]string, 0, len(enabled))
+ for k, v := range enabled {
pairs = append(pairs, fmt.Sprintf("%s=%t", k, v))
}
sort.Strings(pairs)
diff --git a/pkg/proto/udp/udp.go b/pkg/proto/udp/udp.go
index 0ad8aae1..21c41680 100644
--- a/pkg/proto/udp/udp.go
+++ b/pkg/proto/udp/udp.go
@@ -15,7 +15,6 @@
package udp
import (
- "encoding/base64"
"net"
"sync"
"time"
@@ -28,16 +27,17 @@ import (
)
func NewUDPPacket(buf []byte, laddr, raddr *net.UDPAddr) *msg.UDPPacket {
+ content := make([]byte, len(buf))
+ copy(content, buf)
return &msg.UDPPacket{
- Content: base64.StdEncoding.EncodeToString(buf),
+ Content: content,
LocalAddr: laddr,
RemoteAddr: raddr,
}
}
func GetContent(m *msg.UDPPacket) (buf []byte, err error) {
- buf, err = base64.StdEncoding.DecodeString(m.Content)
- return
+ return m.Content, nil
}
func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh chan<- *msg.UDPPacket, bufSize int) {
@@ -60,7 +60,7 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh
if err != nil {
return
}
- // buf[:n] will be encoded to string, so the bytes can be reused
+ // NewUDPPacket copies buf[:n], so the read buffer can be reused
udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr)
select {
@@ -85,6 +85,7 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
}()
buf := pool.GetBuf(bufSize)
+ defer pool.PutBuf(buf)
for {
_ = udpConn.SetReadDeadline(time.Now().Add(30 * time.Second))
n, _, err := udpConn.ReadFromUDP(buf)
diff --git a/pkg/sdk/client/client.go b/pkg/sdk/client/client.go
index dfad996d..088d3f8f 100644
--- a/pkg/sdk/client/client.go
+++ b/pkg/sdk/client/client.go
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
- "github.com/fatedier/frp/client/api"
+ "github.com/fatedier/frp/client/http/model"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
@@ -32,7 +32,7 @@ func (c *Client) SetAuth(user, pwd string) {
c.authPwd = pwd
}
-func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxyStatusResp, error) {
+func (c *Client) GetProxyStatus(ctx context.Context, name string) (*model.ProxyStatusResp, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
if err != nil {
return nil, err
@@ -41,7 +41,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxySta
if err != nil {
return nil, err
}
- allStatus := make(api.StatusResp)
+ allStatus := make(model.StatusResp)
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
}
@@ -55,7 +55,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxySta
return nil, fmt.Errorf("no proxy status found")
}
-func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error) {
+func (c *Client) GetAllProxyStatus(ctx context.Context) (model.StatusResp, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
if err != nil {
return nil, err
@@ -64,7 +64,7 @@ func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error)
if err != nil {
return nil, err
}
- allStatus := make(api.StatusResp)
+ allStatus := make(model.StatusResp)
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
}
diff --git a/pkg/ssh/server.go b/pkg/ssh/server.go
index 378c6098..bffc40bc 100644
--- a/pkg/ssh/server.go
+++ b/pkg/ssh/server.go
@@ -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,
diff --git a/pkg/transport/tls.go b/pkg/transport/tls.go
index e8d2bf48..19ebca73 100644
--- a/pkg/transport/tls.go
+++ b/pkg/transport/tls.go
@@ -20,6 +20,7 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
+ "fmt"
"math/big"
"os"
"time"
@@ -85,7 +86,9 @@ func newCertPool(caPath string) (*x509.CertPool, error) {
return nil, err
}
- pool.AppendCertsFromPEM(caCrt)
+ if !pool.AppendCertsFromPEM(caCrt) {
+ return nil, fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath)
+ }
return pool, nil
}
diff --git a/pkg/util/http/http.go b/pkg/util/http/http.go
index b85a46a3..8a763727 100644
--- a/pkg/util/http/http.go
+++ b/pkg/util/http/http.go
@@ -89,11 +89,11 @@ func ParseBasicAuth(auth string) (username, password string, ok bool) {
return
}
cs := string(c)
- s := strings.IndexByte(cs, ':')
- if s < 0 {
+ before, after, found := strings.Cut(cs, ":")
+ if !found {
return
}
- return cs[:s], cs[s+1:], true
+ return before, after, true
}
func BasicAuth(username, passwd string) string {
diff --git a/pkg/util/http/server.go b/pkg/util/http/server.go
index 99bed364..0bca8993 100644
--- a/pkg/util/http/server.go
+++ b/pkg/util/http/server.go
@@ -100,7 +100,11 @@ func (s *Server) Run() error {
}
func (s *Server) Close() error {
- return s.hs.Close()
+ err := s.hs.Close()
+ if s.ln != nil {
+ _ = s.ln.Close()
+ }
+ return err
}
type RouterRegisterHelper struct {
diff --git a/pkg/util/jsonx/json_v1.go b/pkg/util/jsonx/json_v1.go
new file mode 100644
index 00000000..1fb98290
--- /dev/null
+++ b/pkg/util/jsonx/json_v1.go
@@ -0,0 +1,45 @@
+// 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 jsonx
+
+import (
+ "bytes"
+ "encoding/json"
+)
+
+type DecodeOptions struct {
+ RejectUnknownMembers bool
+}
+
+func Marshal(v any) ([]byte, error) {
+ return json.Marshal(v)
+}
+
+func MarshalIndent(v any, prefix, indent string) ([]byte, error) {
+ return json.MarshalIndent(v, prefix, indent)
+}
+
+func Unmarshal(data []byte, out any) error {
+ return json.Unmarshal(data, out)
+}
+
+func UnmarshalWithOptions(data []byte, out any, options DecodeOptions) error {
+ if !options.RejectUnknownMembers {
+ return json.Unmarshal(data, out)
+ }
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ return decoder.Decode(out)
+}
diff --git a/pkg/util/jsonx/raw_message.go b/pkg/util/jsonx/raw_message.go
new file mode 100644
index 00000000..3eda4628
--- /dev/null
+++ b/pkg/util/jsonx/raw_message.go
@@ -0,0 +1,36 @@
+// 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 jsonx
+
+import "fmt"
+
+// RawMessage stores a raw encoded JSON value.
+// It is equivalent to encoding/json.RawMessage behavior.
+type RawMessage []byte
+
+func (m RawMessage) MarshalJSON() ([]byte, error) {
+ if m == nil {
+ return []byte("null"), nil
+ }
+ return m, nil
+}
+
+func (m *RawMessage) UnmarshalJSON(data []byte) error {
+ if m == nil {
+ return fmt.Errorf("jsonx.RawMessage: UnmarshalJSON on nil pointer")
+ }
+ *m = append((*m)[:0], data...)
+ return nil
+}
diff --git a/pkg/util/net/udp.go b/pkg/util/net/udp.go
index 79ebbb14..aa4d9834 100644
--- a/pkg/util/net/udp.go
+++ b/pkg/util/net/udp.go
@@ -86,11 +86,7 @@ func (c *FakeUDPConn) Read(b []byte) (n int, err error) {
c.lastActive = time.Now()
c.mu.Unlock()
- if len(b) < len(content) {
- n = len(b)
- } else {
- n = len(content)
- }
+ n = min(len(b), len(content))
copy(b, content)
return n, nil
}
@@ -168,11 +164,15 @@ func ListenUDP(bindAddr string, bindPort int) (l *UDPListener, err error) {
return l, err
}
readConn, err := net.ListenUDP("udp", udpAddr)
+ if err != nil {
+ return l, err
+ }
l = &UDPListener{
addr: udpAddr,
acceptCh: make(chan net.Conn),
writeCh: make(chan *UDPPacket, 1000),
+ readConn: readConn,
fakeConns: make(map[string]*FakeUDPConn),
}
diff --git a/pkg/util/net/websocket.go b/pkg/util/net/websocket.go
index 3ca8b332..6c2f39c4 100644
--- a/pkg/util/net/websocket.go
+++ b/pkg/util/net/websocket.go
@@ -26,6 +26,7 @@ type WebsocketListener struct {
// ln: tcp listener for websocket connections
func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
wl = &WebsocketListener{
+ ln: ln,
acceptCh: make(chan net.Conn),
}
diff --git a/pkg/util/util/util.go b/pkg/util/util/util.go
index 774af2cf..31997485 100644
--- a/pkg/util/util/util.go
+++ b/pkg/util/util/util.go
@@ -68,8 +68,8 @@ func ParseRangeNumbers(rangeStr string) (numbers []int64, err error) {
rangeStr = strings.TrimSpace(rangeStr)
numbers = make([]int64, 0)
// e.g. 1000-2000,2001,2002,3000-4000
- numRanges := strings.Split(rangeStr, ",")
- for _, numRangeStr := range numRanges {
+ numRanges := strings.SplitSeq(rangeStr, ",")
+ for numRangeStr := range numRanges {
// 1000-2000 or 2001
numArray := strings.Split(numRangeStr, "-")
// length: only 1 or 2 is correct
@@ -134,3 +134,12 @@ func RandomSleep(duration time.Duration, minRatio, maxRatio float64) time.Durati
func ConstantTimeEqString(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+
+// ClonePtr returns a pointer to a copied value. If v is nil, it returns nil.
+func ClonePtr[T any](v *T) *T {
+ if v == nil {
+ return nil
+ }
+ out := *v
+ return &out
+}
diff --git a/pkg/util/util/util_test.go b/pkg/util/util/util_test.go
index 0a63ba6d..bd53f48e 100644
--- a/pkg/util/util/util_test.go
+++ b/pkg/util/util/util_test.go
@@ -41,3 +41,16 @@ func TestParseRangeNumbers(t *testing.T) {
_, err = ParseRangeNumbers("3-a")
require.Error(err)
}
+
+func TestClonePtr(t *testing.T) {
+ require := require.New(t)
+
+ var nilInt *int
+ require.Nil(ClonePtr(nilInt))
+
+ v := 42
+ cloned := ClonePtr(&v)
+ require.NotNil(cloned)
+ require.Equal(v, *cloned)
+ require.NotSame(&v, cloned)
+}
diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go
index 4a601f32..c98b7cfb 100644
--- a/pkg/util/version/version.go
+++ b/pkg/util/version/version.go
@@ -14,7 +14,7 @@
package version
-var version = "0.67.0"
+var version = "0.68.0"
func Full() string {
return version
diff --git a/pkg/util/vhost/http.go b/pkg/util/vhost/http.go
index 05ec174b..d12e7916 100644
--- a/pkg/util/vhost/http.go
+++ b/pkg/util/vhost/http.go
@@ -266,31 +266,13 @@ func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Req
go libio.Join(remote, client)
}
-func parseBasicAuth(auth string) (username, password string, ok bool) {
- const prefix = "Basic "
- // Case insensitive prefix match. See Issue 22736.
- if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
- return
- }
- c, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
- if err != nil {
- return
- }
- cs := string(c)
- s := strings.IndexByte(cs, ':')
- if s < 0 {
- return
- }
- return cs[:s], cs[s+1:], true
-}
-
func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Request {
user := ""
// If url host isn't empty, it's a proxy request. Get http user from Proxy-Authorization header.
if req.URL.Host != "" {
proxyAuth := req.Header.Get("Proxy-Authorization")
if proxyAuth != "" {
- user, _, _ = parseBasicAuth(proxyAuth)
+ user, _, _ = httppkg.ParseBasicAuth(proxyAuth)
}
}
if user == "" {
diff --git a/pkg/util/xlog/xlog.go b/pkg/util/xlog/xlog.go
index a1a58d42..f8f4116a 100644
--- a/pkg/util/xlog/xlog.go
+++ b/pkg/util/xlog/xlog.go
@@ -63,11 +63,12 @@ func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
if prefix.Priority <= 0 {
prefix.Priority = 10
}
- for _, p := range l.prefixes {
+ for i, p := range l.prefixes {
if p.Name == prefix.Name {
found = true
- p.Value = prefix.Value
- p.Priority = prefix.Priority
+ l.prefixes[i].Value = prefix.Value
+ l.prefixes[i].Priority = prefix.Priority
+ break
}
}
if !found {
diff --git a/pkg/virtual/client.go b/pkg/virtual/client.go
index 8fec28c8..c7006ce7 100644
--- a/pkg/virtual/client.go
+++ b/pkg/virtual/client.go
@@ -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,
diff --git a/pkg/vnet/controller.go b/pkg/vnet/controller.go
index ca71a8c3..d5c97c66 100644
--- a/pkg/vnet/controller.go
+++ b/pkg/vnet/controller.go
@@ -131,6 +131,9 @@ func (c *Controller) handlePacket(buf []byte) {
}
func (c *Controller) Stop() error {
+ if c.tun == nil {
+ return nil
+ }
return c.tun.Close()
}
diff --git a/server/api_router.go b/server/api_router.go
new file mode 100644
index 00000000..bb9e44ed
--- /dev/null
+++ b/server/api_router.go
@@ -0,0 +1,64 @@
+// Copyright 2017 fatedier, fatedier@gmail.com
+//
+// 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 server
+
+import (
+ "net/http"
+
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+
+ httppkg "github.com/fatedier/frp/pkg/util/http"
+ netpkg "github.com/fatedier/frp/pkg/util/net"
+ adminapi "github.com/fatedier/frp/server/http"
+)
+
+func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
+ helper.Router.HandleFunc("/healthz", healthz)
+ subRouter := helper.Router.NewRoute().Subrouter()
+
+ subRouter.Use(helper.AuthMiddleware)
+ subRouter.Use(httppkg.NewRequestLogger)
+
+ // metrics
+ if svr.cfg.EnablePrometheus {
+ subRouter.Handle("/metrics", promhttp.Handler())
+ }
+
+ apiController := adminapi.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
+
+ // apis
+ subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
+ subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
+ subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
+ subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
+ subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
+ subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
+ subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
+ subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
+
+ // view
+ subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
+ subRouter.PathPrefix("/static/").Handler(
+ netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
+ ).Methods("GET")
+
+ subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
+ })
+}
+
+func healthz(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(200)
+}
diff --git a/server/control.go b/server/control.go
index 863104f3..314669ff 100644
--- a/server/control.go
+++ b/server/control.go
@@ -95,20 +95,33 @@ func (cm *ControlManager) Close() error {
return nil
}
-type Control struct {
+// SessionContext encapsulates the input parameters for creating a new Control.
+type SessionContext struct {
// all resource managers and controllers
- rc *controller.ResourceController
-
+ RC *controller.ResourceController
// proxy manager
- pxyManager *proxy.Manager
-
+ PxyManager *proxy.Manager
// plugin manager
- pluginManager *plugin.Manager
-
+ PluginManager *plugin.Manager
// verifies authentication based on selected method
- authVerifier auth.Verifier
+ AuthVerifier auth.Verifier
// key used for connection encryption
- encryptionKey []byte
+ EncryptionKey []byte
+ // control connection
+ Conn net.Conn
+ // indicates whether the connection is encrypted
+ ConnEncrypted bool
+ // login message
+ LoginMsg *msg.Login
+ // server configuration
+ ServerCfg *v1.ServerConfig
+ // client registry
+ ClientRegistry *registry.ClientRegistry
+}
+
+type Control struct {
+ // session context
+ sessionCtx *SessionContext
// other components can use this to communicate with client
msgTransporter transport.MessageTransporter
@@ -117,12 +130,6 @@ type Control struct {
// It provides a channel for sending messages, and you can register handlers to process messages based on their respective types.
msgDispatcher *msg.Dispatcher
- // login message
- loginMsg *msg.Login
-
- // control connection
- conn net.Conn
-
// work connections
workConnCh chan net.Conn
@@ -145,61 +152,34 @@ type Control struct {
mu sync.RWMutex
- // Server configuration information
- serverCfg *v1.ServerConfig
-
- clientRegistry *registry.ClientRegistry
-
xl *xlog.Logger
ctx context.Context
doneCh chan struct{}
}
-// TODO(fatedier): Referencing the implementation of frpc, encapsulate the input parameters as SessionContext.
-func NewControl(
- ctx context.Context,
- rc *controller.ResourceController,
- pxyManager *proxy.Manager,
- pluginManager *plugin.Manager,
- authVerifier auth.Verifier,
- encryptionKey []byte,
- ctlConn net.Conn,
- ctlConnEncrypted bool,
- loginMsg *msg.Login,
- serverCfg *v1.ServerConfig,
-) (*Control, error) {
- poolCount := loginMsg.PoolCount
- if poolCount > int(serverCfg.Transport.MaxPoolCount) {
- poolCount = int(serverCfg.Transport.MaxPoolCount)
- }
+func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
+ poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))
ctl := &Control{
- rc: rc,
- pxyManager: pxyManager,
- pluginManager: pluginManager,
- authVerifier: authVerifier,
- encryptionKey: encryptionKey,
- conn: ctlConn,
- loginMsg: loginMsg,
- workConnCh: make(chan net.Conn, poolCount+10),
- proxies: make(map[string]proxy.Proxy),
- poolCount: poolCount,
- portsUsedNum: 0,
- runID: loginMsg.RunID,
- serverCfg: serverCfg,
- xl: xlog.FromContextSafe(ctx),
- ctx: ctx,
- doneCh: make(chan struct{}),
+ sessionCtx: sessionCtx,
+ workConnCh: make(chan net.Conn, poolCount+10),
+ proxies: make(map[string]proxy.Proxy),
+ poolCount: poolCount,
+ portsUsedNum: 0,
+ runID: sessionCtx.LoginMsg.RunID,
+ xl: xlog.FromContextSafe(ctx),
+ ctx: ctx,
+ doneCh: make(chan struct{}),
}
ctl.lastPing.Store(time.Now())
- if ctlConnEncrypted {
- cryptoRW, err := netpkg.NewCryptoReadWriter(ctl.conn, ctl.encryptionKey)
+ if sessionCtx.ConnEncrypted {
+ cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.EncryptionKey)
if err != nil {
return nil, err
}
ctl.msgDispatcher = msg.NewDispatcher(cryptoRW)
} else {
- ctl.msgDispatcher = msg.NewDispatcher(ctl.conn)
+ ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
}
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
@@ -213,7 +193,7 @@ func (ctl *Control) Start() {
RunID: ctl.runID,
Error: "",
}
- _ = msg.WriteMsg(ctl.conn, loginRespMsg)
+ _ = msg.WriteMsg(ctl.sessionCtx.Conn, loginRespMsg)
go func() {
for i := 0; i < ctl.poolCount; i++ {
@@ -225,7 +205,7 @@ func (ctl *Control) Start() {
}
func (ctl *Control) Close() error {
- ctl.conn.Close()
+ ctl.sessionCtx.Conn.Close()
return nil
}
@@ -233,7 +213,7 @@ func (ctl *Control) Replaced(newCtl *Control) {
xl := ctl.xl
xl.Infof("replaced by client [%s]", newCtl.runID)
ctl.runID = ""
- ctl.conn.Close()
+ ctl.sessionCtx.Conn.Close()
}
func (ctl *Control) RegisterWorkConn(conn net.Conn) error {
@@ -291,7 +271,7 @@ func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) {
return
}
- case <-time.After(time.Duration(ctl.serverCfg.UserConnTimeout) * time.Second):
+ case <-time.After(time.Duration(ctl.sessionCtx.ServerCfg.UserConnTimeout) * time.Second):
err = fmt.Errorf("timeout trying to get work connection")
xl.Warnf("%v", err)
return
@@ -304,15 +284,15 @@ func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) {
}
func (ctl *Control) heartbeatWorker() {
- if ctl.serverCfg.Transport.HeartbeatTimeout <= 0 {
+ if ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout <= 0 {
return
}
xl := ctl.xl
go wait.Until(func() {
- if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.serverCfg.Transport.HeartbeatTimeout)*time.Second {
+ if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.sessionCtx.ServerCfg.Transport.HeartbeatTimeout)*time.Second {
xl.Warnf("heartbeat timeout")
- ctl.conn.Close()
+ ctl.sessionCtx.Conn.Close()
return
}
}, time.Second, ctl.doneCh)
@@ -323,6 +303,30 @@ func (ctl *Control) WaitClosed() {
<-ctl.doneCh
}
+func (ctl *Control) loginUserInfo() plugin.UserInfo {
+ return plugin.UserInfo{
+ User: ctl.sessionCtx.LoginMsg.User,
+ Metas: ctl.sessionCtx.LoginMsg.Metas,
+ RunID: ctl.sessionCtx.LoginMsg.RunID,
+ }
+}
+
+func (ctl *Control) closeProxy(pxy proxy.Proxy) {
+ pxy.Close()
+ ctl.sessionCtx.PxyManager.Del(pxy.GetName())
+ metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
+
+ notifyContent := &plugin.CloseProxyContent{
+ User: ctl.loginUserInfo(),
+ CloseProxy: msg.CloseProxy{
+ ProxyName: pxy.GetName(),
+ },
+ }
+ go func() {
+ _ = ctl.sessionCtx.PluginManager.CloseProxy(notifyContent)
+ }()
+}
+
func (ctl *Control) worker() {
xl := ctl.xl
@@ -330,38 +334,23 @@ func (ctl *Control) worker() {
go ctl.msgDispatcher.Run()
<-ctl.msgDispatcher.Done()
- ctl.conn.Close()
+ ctl.sessionCtx.Conn.Close()
ctl.mu.Lock()
- defer ctl.mu.Unlock()
-
close(ctl.workConnCh)
for workConn := range ctl.workConnCh {
workConn.Close()
}
+ proxies := ctl.proxies
+ ctl.proxies = make(map[string]proxy.Proxy)
+ ctl.mu.Unlock()
- for _, pxy := range ctl.proxies {
- pxy.Close()
- ctl.pxyManager.Del(pxy.GetName())
- metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
-
- notifyContent := &plugin.CloseProxyContent{
- User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
- },
- CloseProxy: msg.CloseProxy{
- ProxyName: pxy.GetName(),
- },
- }
- go func() {
- _ = ctl.pluginManager.CloseProxy(notifyContent)
- }()
+ for _, pxy := range proxies {
+ ctl.closeProxy(pxy)
}
metrics.Server.CloseClient()
- ctl.clientRegistry.MarkOfflineByRunID(ctl.runID)
+ ctl.sessionCtx.ClientRegistry.MarkOfflineByRunID(ctl.runID)
xl.Infof("client exit success")
close(ctl.doneCh)
}
@@ -380,15 +369,11 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
inMsg := m.(*msg.NewProxy)
content := &plugin.NewProxyContent{
- User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
- },
+ User: ctl.loginUserInfo(),
NewProxy: *inMsg,
}
var remoteAddr string
- retContent, err := ctl.pluginManager.NewProxy(content)
+ retContent, err := ctl.sessionCtx.PluginManager.NewProxy(content)
if err == nil {
inMsg = &retContent.NewProxy
remoteAddr, err = ctl.RegisterProxy(inMsg)
@@ -401,15 +386,15 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
if err != nil {
xl.Warnf("new proxy [%s] type [%s] error: %v", inMsg.ProxyName, inMsg.ProxyType, err)
resp.Error = util.GenerateResponseErrorString(fmt.Sprintf("new proxy [%s] error", inMsg.ProxyName),
- err, lo.FromPtr(ctl.serverCfg.DetailedErrorsToClient))
+ err, lo.FromPtr(ctl.sessionCtx.ServerCfg.DetailedErrorsToClient))
} else {
resp.RemoteAddr = remoteAddr
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
- clientID := ctl.loginMsg.ClientID
+ clientID := ctl.sessionCtx.LoginMsg.ClientID
if clientID == "" {
- clientID = ctl.loginMsg.RunID
+ clientID = ctl.sessionCtx.LoginMsg.RunID
}
- metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID)
+ metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.sessionCtx.LoginMsg.User, clientID)
}
_ = ctl.msgDispatcher.Send(resp)
}
@@ -419,22 +404,18 @@ func (ctl *Control) handlePing(m msg.Message) {
inMsg := m.(*msg.Ping)
content := &plugin.PingContent{
- User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
- },
+ User: ctl.loginUserInfo(),
Ping: *inMsg,
}
- retContent, err := ctl.pluginManager.Ping(content)
+ retContent, err := ctl.sessionCtx.PluginManager.Ping(content)
if err == nil {
inMsg = &retContent.Ping
- err = ctl.authVerifier.VerifyPing(inMsg)
+ err = ctl.sessionCtx.AuthVerifier.VerifyPing(inMsg)
}
if err != nil {
xl.Warnf("received invalid ping: %v", err)
_ = ctl.msgDispatcher.Send(&msg.Pong{
- Error: util.GenerateResponseErrorString("invalid ping", err, lo.FromPtr(ctl.serverCfg.DetailedErrorsToClient)),
+ Error: util.GenerateResponseErrorString("invalid ping", err, lo.FromPtr(ctl.sessionCtx.ServerCfg.DetailedErrorsToClient)),
})
return
}
@@ -445,17 +426,17 @@ func (ctl *Control) handlePing(m msg.Message) {
func (ctl *Control) handleNatHoleVisitor(m msg.Message) {
inMsg := m.(*msg.NatHoleVisitor)
- ctl.rc.NatHoleController.HandleVisitor(inMsg, ctl.msgTransporter, ctl.loginMsg.User)
+ ctl.sessionCtx.RC.NatHoleController.HandleVisitor(inMsg, ctl.msgTransporter, ctl.sessionCtx.LoginMsg.User)
}
func (ctl *Control) handleNatHoleClient(m msg.Message) {
inMsg := m.(*msg.NatHoleClient)
- ctl.rc.NatHoleController.HandleClient(inMsg, ctl.msgTransporter)
+ ctl.sessionCtx.RC.NatHoleController.HandleClient(inMsg, ctl.msgTransporter)
}
func (ctl *Control) handleNatHoleReport(m msg.Message) {
inMsg := m.(*msg.NatHoleReport)
- ctl.rc.NatHoleController.HandleReport(inMsg)
+ ctl.sessionCtx.RC.NatHoleController.HandleReport(inMsg)
}
func (ctl *Control) handleCloseProxy(m msg.Message) {
@@ -468,15 +449,15 @@ func (ctl *Control) handleCloseProxy(m msg.Message) {
func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err error) {
var pxyConf v1.ProxyConfigurer
// Load configures from NewProxy message and validate.
- pxyConf, err = config.NewProxyConfigurerFromMsg(pxyMsg, ctl.serverCfg)
+ pxyConf, err = config.NewProxyConfigurerFromMsg(pxyMsg, ctl.sessionCtx.ServerCfg)
if err != nil {
return
}
// User info
userInfo := plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
+ User: ctl.sessionCtx.LoginMsg.User,
+ Metas: ctl.sessionCtx.LoginMsg.Metas,
RunID: ctl.runID,
}
@@ -484,22 +465,22 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
// In fact, it creates different proxies based on the proxy type. We just call run() here.
pxy, err := proxy.NewProxy(ctl.ctx, &proxy.Options{
UserInfo: userInfo,
- LoginMsg: ctl.loginMsg,
+ LoginMsg: ctl.sessionCtx.LoginMsg,
PoolCount: ctl.poolCount,
- ResourceController: ctl.rc,
+ ResourceController: ctl.sessionCtx.RC,
GetWorkConnFn: ctl.GetWorkConn,
Configurer: pxyConf,
- ServerCfg: ctl.serverCfg,
- EncryptionKey: ctl.encryptionKey,
+ ServerCfg: ctl.sessionCtx.ServerCfg,
+ EncryptionKey: ctl.sessionCtx.EncryptionKey,
})
if err != nil {
return remoteAddr, err
}
// Check ports used number in each client
- if ctl.serverCfg.MaxPortsPerClient > 0 {
+ if ctl.sessionCtx.ServerCfg.MaxPortsPerClient > 0 {
ctl.mu.Lock()
- if ctl.portsUsedNum+pxy.GetUsedPortsNum() > int(ctl.serverCfg.MaxPortsPerClient) {
+ if ctl.portsUsedNum+pxy.GetUsedPortsNum() > int(ctl.sessionCtx.ServerCfg.MaxPortsPerClient) {
ctl.mu.Unlock()
err = fmt.Errorf("exceed the max_ports_per_client")
return
@@ -516,7 +497,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
}()
}
- if ctl.pxyManager.Exist(pxyMsg.ProxyName) {
+ if ctl.sessionCtx.PxyManager.Exist(pxyMsg.ProxyName) {
err = fmt.Errorf("proxy [%s] already exists", pxyMsg.ProxyName)
return
}
@@ -531,7 +512,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
}
}()
- err = ctl.pxyManager.Add(pxyMsg.ProxyName, pxy)
+ err = ctl.sessionCtx.PxyManager.Add(pxyMsg.ProxyName, pxy)
if err != nil {
return
}
@@ -550,28 +531,12 @@ func (ctl *Control) CloseProxy(closeMsg *msg.CloseProxy) (err error) {
return
}
- if ctl.serverCfg.MaxPortsPerClient > 0 {
+ if ctl.sessionCtx.ServerCfg.MaxPortsPerClient > 0 {
ctl.portsUsedNum -= pxy.GetUsedPortsNum()
}
- pxy.Close()
- ctl.pxyManager.Del(pxy.GetName())
delete(ctl.proxies, closeMsg.ProxyName)
ctl.mu.Unlock()
- metrics.Server.CloseProxy(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type)
-
- notifyContent := &plugin.CloseProxyContent{
- User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
- },
- CloseProxy: msg.CloseProxy{
- ProxyName: pxy.GetName(),
- },
- }
- go func() {
- _ = ctl.pluginManager.CloseProxy(notifyContent)
- }()
+ ctl.closeProxy(pxy)
return
}
diff --git a/server/group/base.go b/server/group/base.go
new file mode 100644
index 00000000..5684d8ef
--- /dev/null
+++ b/server/group/base.go
@@ -0,0 +1,77 @@
+package group
+
+import (
+ "net"
+ "sync"
+
+ gerr "github.com/fatedier/golib/errors"
+)
+
+// baseGroup contains the shared plumbing for listener-based groups
+// (TCP, HTTPS, TCPMux). Each concrete group embeds this and provides
+// its own Listen method with protocol-specific validation.
+type baseGroup struct {
+ group string
+ groupKey string
+
+ acceptCh chan net.Conn
+ realLn net.Listener
+ lns []*Listener
+ mu sync.Mutex
+ cleanupFn func()
+}
+
+// initBase resets the baseGroup for a fresh listen cycle.
+// Must be called under mu when len(lns) == 0.
+func (bg *baseGroup) initBase(group, groupKey string, realLn net.Listener, cleanupFn func()) {
+ bg.group = group
+ bg.groupKey = groupKey
+ bg.realLn = realLn
+ bg.acceptCh = make(chan net.Conn)
+ bg.cleanupFn = cleanupFn
+}
+
+// worker reads from the real listener and fans out to acceptCh.
+// The parameters are captured at creation time so that the worker is
+// bound to a specific listen cycle and cannot observe a later initBase.
+func (bg *baseGroup) worker(realLn net.Listener, acceptCh chan<- net.Conn) {
+ for {
+ c, err := realLn.Accept()
+ if err != nil {
+ return
+ }
+ err = gerr.PanicToError(func() {
+ acceptCh <- c
+ })
+ if err != nil {
+ c.Close()
+ return
+ }
+ }
+}
+
+// newListener creates a new Listener wired to this baseGroup.
+// Must be called under mu.
+func (bg *baseGroup) newListener(addr net.Addr) *Listener {
+ ln := newListener(bg.acceptCh, addr, bg.closeListener)
+ bg.lns = append(bg.lns, ln)
+ return ln
+}
+
+// closeListener removes ln from the list. When the last listener is removed,
+// it closes acceptCh, closes the real listener, and calls cleanupFn.
+func (bg *baseGroup) closeListener(ln *Listener) {
+ bg.mu.Lock()
+ defer bg.mu.Unlock()
+ for i, l := range bg.lns {
+ if l == ln {
+ bg.lns = append(bg.lns[:i], bg.lns[i+1:]...)
+ break
+ }
+ }
+ if len(bg.lns) == 0 {
+ close(bg.acceptCh)
+ bg.realLn.Close()
+ bg.cleanupFn()
+ }
+}
diff --git a/server/group/base_test.go b/server/group/base_test.go
new file mode 100644
index 00000000..1b470841
--- /dev/null
+++ b/server/group/base_test.go
@@ -0,0 +1,169 @@
+package group
+
+import (
+ "net"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// fakeLn is a controllable net.Listener for tests.
+type fakeLn struct {
+ connCh chan net.Conn
+ closed chan struct{}
+ once sync.Once
+}
+
+func newFakeLn() *fakeLn {
+ return &fakeLn{
+ connCh: make(chan net.Conn, 8),
+ closed: make(chan struct{}),
+ }
+}
+
+func (f *fakeLn) Accept() (net.Conn, error) {
+ select {
+ case c := <-f.connCh:
+ return c, nil
+ case <-f.closed:
+ return nil, net.ErrClosed
+ }
+}
+
+func (f *fakeLn) Close() error {
+ f.once.Do(func() { close(f.closed) })
+ return nil
+}
+
+func (f *fakeLn) Addr() net.Addr { return fakeAddr("127.0.0.1:9999") }
+
+func (f *fakeLn) inject(c net.Conn) {
+ select {
+ case f.connCh <- c:
+ case <-f.closed:
+ }
+}
+
+func TestBaseGroup_WorkerFanOut(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ bg.initBase("g", "key", fl, func() {})
+
+ go bg.worker(fl, bg.acceptCh)
+
+ c1, c2 := net.Pipe()
+ defer c2.Close()
+ fl.inject(c1)
+
+ select {
+ case got := <-bg.acceptCh:
+ assert.Equal(t, c1, got)
+ got.Close()
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for connection on acceptCh")
+ }
+
+ fl.Close()
+}
+
+func TestBaseGroup_WorkerStopsOnListenerClose(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ bg.initBase("g", "key", fl, func() {})
+
+ done := make(chan struct{})
+ go func() {
+ bg.worker(fl, bg.acceptCh)
+ close(done)
+ }()
+
+ fl.Close()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("worker did not stop after listener close")
+ }
+}
+
+func TestBaseGroup_WorkerClosesConnOnClosedChannel(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ bg.initBase("g", "key", fl, func() {})
+
+ // Close acceptCh before worker sends.
+ close(bg.acceptCh)
+
+ done := make(chan struct{})
+ go func() {
+ bg.worker(fl, bg.acceptCh)
+ close(done)
+ }()
+
+ c1, c2 := net.Pipe()
+ defer c2.Close()
+ fl.inject(c1)
+
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("worker did not stop after panic recovery")
+ }
+
+ // c1 should have been closed by worker's panic recovery path.
+ buf := make([]byte, 1)
+ _, err := c1.Read(buf)
+ assert.Error(t, err, "connection should be closed by worker")
+}
+
+func TestBaseGroup_CloseLastListenerTriggersCleanup(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ cleanupCalled := 0
+ bg.initBase("g", "key", fl, func() { cleanupCalled++ })
+
+ bg.mu.Lock()
+ ln1 := bg.newListener(fl.Addr())
+ ln2 := bg.newListener(fl.Addr())
+ bg.mu.Unlock()
+
+ go bg.worker(fl, bg.acceptCh)
+
+ ln1.Close()
+ assert.Equal(t, 0, cleanupCalled, "cleanup should not run while listeners remain")
+
+ ln2.Close()
+ assert.Equal(t, 1, cleanupCalled, "cleanup should run after last listener closed")
+}
+
+func TestBaseGroup_CloseOneOfTwoListeners(t *testing.T) {
+ fl := newFakeLn()
+ var bg baseGroup
+ cleanupCalled := 0
+ bg.initBase("g", "key", fl, func() { cleanupCalled++ })
+
+ bg.mu.Lock()
+ ln1 := bg.newListener(fl.Addr())
+ ln2 := bg.newListener(fl.Addr())
+ bg.mu.Unlock()
+
+ go bg.worker(fl, bg.acceptCh)
+
+ ln1.Close()
+ assert.Equal(t, 0, cleanupCalled)
+
+ // ln2 should still receive connections.
+ c1, c2 := net.Pipe()
+ defer c2.Close()
+ fl.inject(c1)
+
+ got, err := ln2.Accept()
+ require.NoError(t, err)
+ assert.Equal(t, c1, got)
+ got.Close()
+
+ ln2.Close()
+ assert.Equal(t, 1, cleanupCalled)
+}
diff --git a/server/group/group.go b/server/group/group.go
index ab38cf45..1fbedf5c 100644
--- a/server/group/group.go
+++ b/server/group/group.go
@@ -24,4 +24,6 @@ var (
ErrListenerClosed = errors.New("group listener closed")
ErrGroupDifferentPort = errors.New("group should have same remote port")
ErrProxyRepeated = errors.New("group proxy repeated")
+
+ errGroupStale = errors.New("stale group reference")
)
diff --git a/server/group/http.go b/server/group/http.go
index 26af595e..dd905581 100644
--- a/server/group/http.go
+++ b/server/group/http.go
@@ -9,53 +9,42 @@ import (
"github.com/fatedier/frp/pkg/util/vhost"
)
+// HTTPGroupController manages HTTP groups that use round-robin
+// callback routing (fundamentally different from listener-based groups).
type HTTPGroupController struct {
- // groups indexed by group name
- groups map[string]*HTTPGroup
-
- // register createConn for each group to vhostRouter.
- // createConn will get a connection from one proxy of the group
+ groupRegistry[*HTTPGroup]
vhostRouter *vhost.Routers
-
- mu sync.Mutex
}
func NewHTTPGroupController(vhostRouter *vhost.Routers) *HTTPGroupController {
return &HTTPGroupController{
- groups: make(map[string]*HTTPGroup),
- vhostRouter: vhostRouter,
+ groupRegistry: newGroupRegistry[*HTTPGroup](),
+ vhostRouter: vhostRouter,
}
}
func (ctl *HTTPGroupController) Register(
proxyName, group, groupKey string,
routeConfig vhost.RouteConfig,
-) (err error) {
- indexKey := group
- ctl.mu.Lock()
- g, ok := ctl.groups[indexKey]
- if !ok {
- g = NewHTTPGroup(ctl)
- ctl.groups[indexKey] = g
+) error {
+ for {
+ g := ctl.getOrCreate(group, func() *HTTPGroup {
+ return NewHTTPGroup(ctl)
+ })
+ err := g.Register(proxyName, group, groupKey, routeConfig)
+ if err == errGroupStale {
+ continue
+ }
+ return err
}
- ctl.mu.Unlock()
-
- return g.Register(proxyName, group, groupKey, routeConfig)
}
func (ctl *HTTPGroupController) UnRegister(proxyName, group string, _ vhost.RouteConfig) {
- indexKey := group
- ctl.mu.Lock()
- defer ctl.mu.Unlock()
- g, ok := ctl.groups[indexKey]
+ g, ok := ctl.get(group)
if !ok {
return
}
-
- isEmpty := g.UnRegister(proxyName)
- if isEmpty {
- delete(ctl.groups, indexKey)
- }
+ g.UnRegister(proxyName)
}
type HTTPGroup struct {
@@ -87,6 +76,9 @@ func (g *HTTPGroup) Register(
) (err error) {
g.mu.Lock()
defer g.mu.Unlock()
+ if !g.ctl.isCurrent(group, func(cur *HTTPGroup) bool { return cur == g }) {
+ return errGroupStale
+ }
if len(g.createFuncs) == 0 {
// the first proxy in this group
tmp := routeConfig // copy object
@@ -123,7 +115,7 @@ func (g *HTTPGroup) Register(
return nil
}
-func (g *HTTPGroup) UnRegister(proxyName string) (isEmpty bool) {
+func (g *HTTPGroup) UnRegister(proxyName string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.createFuncs, proxyName)
@@ -135,10 +127,11 @@ func (g *HTTPGroup) UnRegister(proxyName string) (isEmpty bool) {
}
if len(g.createFuncs) == 0 {
- isEmpty = true
g.ctl.vhostRouter.Del(g.domain, g.location, g.routeByHTTPUser)
+ g.ctl.removeIf(g.group, func(cur *HTTPGroup) bool {
+ return cur == g
+ })
}
- return
}
func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
@@ -151,7 +144,7 @@ func (g *HTTPGroup) createConn(remoteAddr string) (net.Conn, error) {
location := g.location
routeByHTTPUser := g.routeByHTTPUser
if len(g.pxyNames) > 0 {
- name := g.pxyNames[int(newIndex)%len(g.pxyNames)]
+ name := g.pxyNames[newIndex%uint64(len(g.pxyNames))]
f = g.createFuncs[name]
}
g.mu.RUnlock()
@@ -174,7 +167,7 @@ func (g *HTTPGroup) chooseEndpoint() (string, error) {
location := g.location
routeByHTTPUser := g.routeByHTTPUser
if len(g.pxyNames) > 0 {
- name = g.pxyNames[int(newIndex)%len(g.pxyNames)]
+ name = g.pxyNames[newIndex%uint64(len(g.pxyNames))]
}
g.mu.RUnlock()
diff --git a/server/group/https.go b/server/group/https.go
index 4089b0cb..1ab97578 100644
--- a/server/group/https.go
+++ b/server/group/https.go
@@ -17,25 +17,19 @@ package group
import (
"context"
"net"
- "sync"
-
- gerr "github.com/fatedier/golib/errors"
"github.com/fatedier/frp/pkg/util/vhost"
)
type HTTPSGroupController struct {
- groups map[string]*HTTPSGroup
-
+ groupRegistry[*HTTPSGroup]
httpsMuxer *vhost.HTTPSMuxer
-
- mu sync.Mutex
}
func NewHTTPSGroupController(httpsMuxer *vhost.HTTPSMuxer) *HTTPSGroupController {
return &HTTPSGroupController{
- groups: make(map[string]*HTTPSGroup),
- httpsMuxer: httpsMuxer,
+ groupRegistry: newGroupRegistry[*HTTPSGroup](),
+ httpsMuxer: httpsMuxer,
}
}
@@ -44,41 +38,28 @@ func (ctl *HTTPSGroupController) Listen(
group, groupKey string,
routeConfig vhost.RouteConfig,
) (l net.Listener, err error) {
- indexKey := group
- ctl.mu.Lock()
- g, ok := ctl.groups[indexKey]
- if !ok {
- g = NewHTTPSGroup(ctl)
- ctl.groups[indexKey] = g
+ for {
+ g := ctl.getOrCreate(group, func() *HTTPSGroup {
+ return NewHTTPSGroup(ctl)
+ })
+ l, err = g.Listen(ctx, group, groupKey, routeConfig)
+ if err == errGroupStale {
+ continue
+ }
+ return
}
- ctl.mu.Unlock()
-
- return g.Listen(ctx, group, groupKey, routeConfig)
-}
-
-func (ctl *HTTPSGroupController) RemoveGroup(group string) {
- ctl.mu.Lock()
- defer ctl.mu.Unlock()
- delete(ctl.groups, group)
}
type HTTPSGroup struct {
- group string
- groupKey string
- domain string
+ baseGroup
- acceptCh chan net.Conn
- httpsLn *vhost.Listener
- lns []*HTTPSGroupListener
- ctl *HTTPSGroupController
- mu sync.Mutex
+ domain string
+ ctl *HTTPSGroupController
}
func NewHTTPSGroup(ctl *HTTPSGroupController) *HTTPSGroup {
return &HTTPSGroup{
- lns: make([]*HTTPSGroupListener, 0),
- ctl: ctl,
- acceptCh: make(chan net.Conn),
+ ctl: ctl,
}
}
@@ -86,23 +67,27 @@ func (g *HTTPSGroup) Listen(
ctx context.Context,
group, groupKey string,
routeConfig vhost.RouteConfig,
-) (ln *HTTPSGroupListener, err error) {
+) (ln *Listener, err error) {
g.mu.Lock()
defer g.mu.Unlock()
+ if !g.ctl.isCurrent(group, func(cur *HTTPSGroup) bool { return cur == g }) {
+ return nil, errGroupStale
+ }
if len(g.lns) == 0 {
// the first listener, listen on the real address
httpsLn, errRet := g.ctl.httpsMuxer.Listen(ctx, &routeConfig)
if errRet != nil {
return nil, errRet
}
- ln = newHTTPSGroupListener(group, g, httpsLn.Addr())
- g.group = group
- g.groupKey = groupKey
g.domain = routeConfig.Domain
- g.httpsLn = httpsLn
- g.lns = append(g.lns, ln)
- go g.worker()
+ g.initBase(group, groupKey, httpsLn, func() {
+ g.ctl.removeIf(g.group, func(cur *HTTPSGroup) bool {
+ return cur == g
+ })
+ })
+ ln = g.newListener(httpsLn.Addr())
+ go g.worker(httpsLn, g.acceptCh)
} else {
// route config in the same group must be equal
if g.group != group || g.domain != routeConfig.Domain {
@@ -111,87 +96,7 @@ func (g *HTTPSGroup) Listen(
if g.groupKey != groupKey {
return nil, ErrGroupAuthFailed
}
- ln = newHTTPSGroupListener(group, g, g.lns[0].Addr())
- g.lns = append(g.lns, ln)
+ ln = g.newListener(g.lns[0].Addr())
}
return
}
-
-func (g *HTTPSGroup) worker() {
- for {
- c, err := g.httpsLn.Accept()
- if err != nil {
- return
- }
- err = gerr.PanicToError(func() {
- g.acceptCh <- c
- })
- if err != nil {
- return
- }
- }
-}
-
-func (g *HTTPSGroup) Accept() <-chan net.Conn {
- return g.acceptCh
-}
-
-func (g *HTTPSGroup) CloseListener(ln *HTTPSGroupListener) {
- g.mu.Lock()
- defer g.mu.Unlock()
- for i, tmpLn := range g.lns {
- if tmpLn == ln {
- g.lns = append(g.lns[:i], g.lns[i+1:]...)
- break
- }
- }
- if len(g.lns) == 0 {
- close(g.acceptCh)
- if g.httpsLn != nil {
- g.httpsLn.Close()
- }
- g.ctl.RemoveGroup(g.group)
- }
-}
-
-type HTTPSGroupListener struct {
- groupName string
- group *HTTPSGroup
-
- addr net.Addr
- closeCh chan struct{}
-}
-
-func newHTTPSGroupListener(name string, group *HTTPSGroup, addr net.Addr) *HTTPSGroupListener {
- return &HTTPSGroupListener{
- groupName: name,
- group: group,
- addr: addr,
- closeCh: make(chan struct{}),
- }
-}
-
-func (ln *HTTPSGroupListener) Accept() (c net.Conn, err error) {
- var ok bool
- select {
- case <-ln.closeCh:
- return nil, ErrListenerClosed
- case c, ok = <-ln.group.Accept():
- if !ok {
- return nil, ErrListenerClosed
- }
- return c, nil
- }
-}
-
-func (ln *HTTPSGroupListener) Addr() net.Addr {
- return ln.addr
-}
-
-func (ln *HTTPSGroupListener) Close() (err error) {
- close(ln.closeCh)
-
- // remove self from HTTPSGroup
- ln.group.CloseListener(ln)
- return
-}
diff --git a/server/group/listener.go b/server/group/listener.go
new file mode 100644
index 00000000..33c5c0df
--- /dev/null
+++ b/server/group/listener.go
@@ -0,0 +1,49 @@
+package group
+
+import (
+ "net"
+ "sync"
+)
+
+// Listener is a per-proxy virtual listener that receives connections
+// from a shared group. It implements net.Listener.
+type Listener struct {
+ acceptCh <-chan net.Conn
+ addr net.Addr
+ closeCh chan struct{}
+ onClose func(*Listener)
+ once sync.Once
+}
+
+func newListener(acceptCh <-chan net.Conn, addr net.Addr, onClose func(*Listener)) *Listener {
+ return &Listener{
+ acceptCh: acceptCh,
+ addr: addr,
+ closeCh: make(chan struct{}),
+ onClose: onClose,
+ }
+}
+
+func (ln *Listener) Accept() (net.Conn, error) {
+ select {
+ case <-ln.closeCh:
+ return nil, ErrListenerClosed
+ case c, ok := <-ln.acceptCh:
+ if !ok {
+ return nil, ErrListenerClosed
+ }
+ return c, nil
+ }
+}
+
+func (ln *Listener) Addr() net.Addr {
+ return ln.addr
+}
+
+func (ln *Listener) Close() error {
+ ln.once.Do(func() {
+ close(ln.closeCh)
+ ln.onClose(ln)
+ })
+ return nil
+}
diff --git a/server/group/listener_test.go b/server/group/listener_test.go
new file mode 100644
index 00000000..4e3e30e6
--- /dev/null
+++ b/server/group/listener_test.go
@@ -0,0 +1,68 @@
+package group
+
+import (
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestListener_Accept(t *testing.T) {
+ acceptCh := make(chan net.Conn, 1)
+ ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
+
+ c1, c2 := net.Pipe()
+ defer c1.Close()
+ defer c2.Close()
+
+ acceptCh <- c1
+ got, err := ln.Accept()
+ require.NoError(t, err)
+ assert.Equal(t, c1, got)
+}
+
+func TestListener_AcceptAfterChannelClose(t *testing.T) {
+ acceptCh := make(chan net.Conn)
+ ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
+
+ close(acceptCh)
+ _, err := ln.Accept()
+ assert.ErrorIs(t, err, ErrListenerClosed)
+}
+
+func TestListener_AcceptAfterListenerClose(t *testing.T) {
+ acceptCh := make(chan net.Conn) // open, not closed
+ ln := newListener(acceptCh, fakeAddr("127.0.0.1:1234"), func(*Listener) {})
+
+ ln.Close()
+ _, err := ln.Accept()
+ assert.ErrorIs(t, err, ErrListenerClosed)
+}
+
+func TestListener_DoubleClose(t *testing.T) {
+ closeCalls := 0
+ ln := newListener(
+ make(chan net.Conn),
+ fakeAddr("127.0.0.1:1234"),
+ func(*Listener) { closeCalls++ },
+ )
+
+ assert.NotPanics(t, func() {
+ ln.Close()
+ ln.Close()
+ })
+ assert.Equal(t, 1, closeCalls, "onClose should be called exactly once")
+}
+
+func TestListener_Addr(t *testing.T) {
+ addr := fakeAddr("10.0.0.1:5555")
+ ln := newListener(make(chan net.Conn), addr, func(*Listener) {})
+ assert.Equal(t, addr, ln.Addr())
+}
+
+// fakeAddr implements net.Addr for testing.
+type fakeAddr string
+
+func (a fakeAddr) Network() string { return "tcp" }
+func (a fakeAddr) String() string { return string(a) }
diff --git a/server/group/registry.go b/server/group/registry.go
new file mode 100644
index 00000000..4064c535
--- /dev/null
+++ b/server/group/registry.go
@@ -0,0 +1,59 @@
+package group
+
+import (
+ "sync"
+)
+
+// groupRegistry is a concurrent map of named groups with
+// automatic creation on first access.
+type groupRegistry[G any] struct {
+ groups map[string]G
+ mu sync.Mutex
+}
+
+func newGroupRegistry[G any]() groupRegistry[G] {
+ return groupRegistry[G]{
+ groups: make(map[string]G),
+ }
+}
+
+func (r *groupRegistry[G]) getOrCreate(key string, newFn func() G) G {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ g, ok := r.groups[key]
+ if !ok {
+ g = newFn()
+ r.groups[key] = g
+ }
+ return g
+}
+
+func (r *groupRegistry[G]) get(key string) (G, bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ g, ok := r.groups[key]
+ return g, ok
+}
+
+// isCurrent returns true if key exists in the registry and matchFn
+// returns true for the stored value.
+func (r *groupRegistry[G]) isCurrent(key string, matchFn func(G) bool) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ g, ok := r.groups[key]
+ return ok && matchFn(g)
+}
+
+// removeIf atomically looks up the group for key, calls fn on it,
+// and removes the entry if fn returns true.
+func (r *groupRegistry[G]) removeIf(key string, fn func(G) bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ g, ok := r.groups[key]
+ if !ok {
+ return
+ }
+ if fn(g) {
+ delete(r.groups, key)
+ }
+}
diff --git a/server/group/registry_test.go b/server/group/registry_test.go
new file mode 100644
index 00000000..106d3998
--- /dev/null
+++ b/server/group/registry_test.go
@@ -0,0 +1,102 @@
+package group
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetOrCreate_New(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ called := 0
+ v := 42
+ got := r.getOrCreate("k", func() *int { called++; return &v })
+ assert.Equal(t, 1, called)
+ assert.Equal(t, &v, got)
+}
+
+func TestGetOrCreate_Existing(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v := 42
+ r.getOrCreate("k", func() *int { return &v })
+
+ called := 0
+ got := r.getOrCreate("k", func() *int { called++; return nil })
+ assert.Equal(t, 0, called)
+ assert.Equal(t, &v, got)
+}
+
+func TestGet_ExistingAndMissing(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v := 1
+ r.getOrCreate("k", func() *int { return &v })
+
+ got, ok := r.get("k")
+ assert.True(t, ok)
+ assert.Equal(t, &v, got)
+
+ _, ok = r.get("missing")
+ assert.False(t, ok)
+}
+
+func TestIsCurrent(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v1 := 1
+ v2 := 2
+ r.getOrCreate("k", func() *int { return &v1 })
+
+ assert.True(t, r.isCurrent("k", func(g *int) bool { return g == &v1 }))
+ assert.False(t, r.isCurrent("k", func(g *int) bool { return g == &v2 }))
+ assert.False(t, r.isCurrent("missing", func(g *int) bool { return true }))
+}
+
+func TestRemoveIf(t *testing.T) {
+ t.Run("removes when fn returns true", func(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v := 1
+ r.getOrCreate("k", func() *int { return &v })
+ r.removeIf("k", func(g *int) bool { return g == &v })
+ _, ok := r.get("k")
+ assert.False(t, ok)
+ })
+
+ t.Run("keeps when fn returns false", func(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ v := 1
+ r.getOrCreate("k", func() *int { return &v })
+ r.removeIf("k", func(g *int) bool { return false })
+ _, ok := r.get("k")
+ assert.True(t, ok)
+ })
+
+ t.Run("noop on missing key", func(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ r.removeIf("missing", func(g *int) bool { return true }) // should not panic
+ })
+}
+
+func TestConcurrentGetOrCreateAndRemoveIf(t *testing.T) {
+ r := newGroupRegistry[*int]()
+ const n = 100
+ var wg sync.WaitGroup
+ wg.Add(n * 2)
+ for i := range n {
+ v := i
+ go func() {
+ defer wg.Done()
+ r.getOrCreate("k", func() *int { return &v })
+ }()
+ go func() {
+ defer wg.Done()
+ r.removeIf("k", func(*int) bool { return true })
+ }()
+ }
+ wg.Wait()
+
+ // After all goroutines finish, accessing the key must not panic.
+ require.NotPanics(t, func() {
+ _, _ = r.get("k")
+ })
+}
diff --git a/server/group/tcp.go b/server/group/tcp.go
index c0dcd5f7..d6bfbcff 100644
--- a/server/group/tcp.go
+++ b/server/group/tcp.go
@@ -17,107 +17,91 @@ package group
import (
"net"
"strconv"
- "sync"
-
- gerr "github.com/fatedier/golib/errors"
"github.com/fatedier/frp/server/ports"
)
-// TCPGroupCtl manage all TCPGroups
+// TCPGroupCtl manages all TCPGroups.
type TCPGroupCtl struct {
- groups map[string]*TCPGroup
-
- // portManager is used to manage port
+ groupRegistry[*TCPGroup]
portManager *ports.Manager
- mu sync.Mutex
}
-// NewTCPGroupCtl return a new TcpGroupCtl
+// NewTCPGroupCtl returns a new TCPGroupCtl.
func NewTCPGroupCtl(portManager *ports.Manager) *TCPGroupCtl {
return &TCPGroupCtl{
- groups: make(map[string]*TCPGroup),
- portManager: portManager,
+ groupRegistry: newGroupRegistry[*TCPGroup](),
+ portManager: portManager,
}
}
-// Listen is the wrapper for TCPGroup's Listen
-// If there are no group, we will create one here
+// Listen is the wrapper for TCPGroup's Listen.
+// If there is no group, one will be created.
func (tgc *TCPGroupCtl) Listen(proxyName string, group string, groupKey string,
addr string, port int,
) (l net.Listener, realPort int, err error) {
- tgc.mu.Lock()
- tcpGroup, ok := tgc.groups[group]
- if !ok {
- tcpGroup = NewTCPGroup(tgc)
- tgc.groups[group] = tcpGroup
+ for {
+ tcpGroup := tgc.getOrCreate(group, func() *TCPGroup {
+ return NewTCPGroup(tgc)
+ })
+ l, realPort, err = tcpGroup.Listen(proxyName, group, groupKey, addr, port)
+ if err == errGroupStale {
+ continue
+ }
+ return
}
- tgc.mu.Unlock()
-
- return tcpGroup.Listen(proxyName, group, groupKey, addr, port)
}
-// RemoveGroup remove TCPGroup from controller
-func (tgc *TCPGroupCtl) RemoveGroup(group string) {
- tgc.mu.Lock()
- defer tgc.mu.Unlock()
- delete(tgc.groups, group)
-}
-
-// TCPGroup route connections to different proxies
+// TCPGroup routes connections to different proxies.
type TCPGroup struct {
- group string
- groupKey string
+ baseGroup
+
addr string
port int
realPort int
-
- acceptCh chan net.Conn
- tcpLn net.Listener
- lns []*TCPGroupListener
ctl *TCPGroupCtl
- mu sync.Mutex
}
-// NewTCPGroup return a new TCPGroup
+// NewTCPGroup returns a new TCPGroup.
func NewTCPGroup(ctl *TCPGroupCtl) *TCPGroup {
return &TCPGroup{
- lns: make([]*TCPGroupListener, 0),
- ctl: ctl,
- acceptCh: make(chan net.Conn),
+ ctl: ctl,
}
}
-// Listen will return a new TCPGroupListener
-// if TCPGroup already has a listener, just add a new TCPGroupListener to the queues
-// otherwise, listen on the real address
-func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr string, port int) (ln *TCPGroupListener, realPort int, err error) {
+// Listen will return a new Listener.
+// If TCPGroup already has a listener, just add a new Listener to the queues,
+// otherwise listen on the real address.
+func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr string, port int) (ln *Listener, realPort int, err error) {
tg.mu.Lock()
defer tg.mu.Unlock()
+ if !tg.ctl.isCurrent(group, func(cur *TCPGroup) bool { return cur == tg }) {
+ return nil, 0, errGroupStale
+ }
if len(tg.lns) == 0 {
// the first listener, listen on the real address
realPort, err = tg.ctl.portManager.Acquire(proxyName, port)
if err != nil {
return
}
- tcpLn, errRet := net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(port)))
+ tcpLn, errRet := net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(realPort)))
if errRet != nil {
+ tg.ctl.portManager.Release(realPort)
err = errRet
return
}
- ln = newTCPGroupListener(group, tg, tcpLn.Addr())
- tg.group = group
- tg.groupKey = groupKey
tg.addr = addr
tg.port = port
tg.realPort = realPort
- tg.tcpLn = tcpLn
- tg.lns = append(tg.lns, ln)
- if tg.acceptCh == nil {
- tg.acceptCh = make(chan net.Conn)
- }
- go tg.worker()
+ tg.initBase(group, groupKey, tcpLn, func() {
+ tg.ctl.portManager.Release(tg.realPort)
+ tg.ctl.removeIf(tg.group, func(cur *TCPGroup) bool {
+ return cur == tg
+ })
+ })
+ ln = tg.newListener(tcpLn.Addr())
+ go tg.worker(tcpLn, tg.acceptCh)
} else {
// address and port in the same group must be equal
if tg.group != group || tg.addr != addr {
@@ -132,92 +116,8 @@ func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr
err = ErrGroupAuthFailed
return
}
- ln = newTCPGroupListener(group, tg, tg.lns[0].Addr())
+ ln = tg.newListener(tg.lns[0].Addr())
realPort = tg.realPort
- tg.lns = append(tg.lns, ln)
}
return
}
-
-// worker is called when the real tcp listener has been created
-func (tg *TCPGroup) worker() {
- for {
- c, err := tg.tcpLn.Accept()
- if err != nil {
- return
- }
- err = gerr.PanicToError(func() {
- tg.acceptCh <- c
- })
- if err != nil {
- return
- }
- }
-}
-
-func (tg *TCPGroup) Accept() <-chan net.Conn {
- return tg.acceptCh
-}
-
-// CloseListener remove the TCPGroupListener from the TCPGroup
-func (tg *TCPGroup) CloseListener(ln *TCPGroupListener) {
- tg.mu.Lock()
- defer tg.mu.Unlock()
- for i, tmpLn := range tg.lns {
- if tmpLn == ln {
- tg.lns = append(tg.lns[:i], tg.lns[i+1:]...)
- break
- }
- }
- if len(tg.lns) == 0 {
- close(tg.acceptCh)
- tg.tcpLn.Close()
- tg.ctl.portManager.Release(tg.realPort)
- tg.ctl.RemoveGroup(tg.group)
- }
-}
-
-// TCPGroupListener
-type TCPGroupListener struct {
- groupName string
- group *TCPGroup
-
- addr net.Addr
- closeCh chan struct{}
-}
-
-func newTCPGroupListener(name string, group *TCPGroup, addr net.Addr) *TCPGroupListener {
- return &TCPGroupListener{
- groupName: name,
- group: group,
- addr: addr,
- closeCh: make(chan struct{}),
- }
-}
-
-// Accept will accept connections from TCPGroup
-func (ln *TCPGroupListener) Accept() (c net.Conn, err error) {
- var ok bool
- select {
- case <-ln.closeCh:
- return nil, ErrListenerClosed
- case c, ok = <-ln.group.Accept():
- if !ok {
- return nil, ErrListenerClosed
- }
- return c, nil
- }
-}
-
-func (ln *TCPGroupListener) Addr() net.Addr {
- return ln.addr
-}
-
-// Close close the listener
-func (ln *TCPGroupListener) Close() (err error) {
- close(ln.closeCh)
-
- // remove self from TcpGroup
- ln.group.CloseListener(ln)
- return
-}
diff --git a/server/group/tcpmux.go b/server/group/tcpmux.go
index 1712bc74..e17a152a 100644
--- a/server/group/tcpmux.go
+++ b/server/group/tcpmux.go
@@ -18,118 +18,100 @@ import (
"context"
"fmt"
"net"
- "sync"
-
- gerr "github.com/fatedier/golib/errors"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/tcpmux"
"github.com/fatedier/frp/pkg/util/vhost"
)
-// TCPMuxGroupCtl manage all TCPMuxGroups
+// TCPMuxGroupCtl manages all TCPMuxGroups.
type TCPMuxGroupCtl struct {
- groups map[string]*TCPMuxGroup
-
- // portManager is used to manage port
+ groupRegistry[*TCPMuxGroup]
tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer
- mu sync.Mutex
}
-// NewTCPMuxGroupCtl return a new TCPMuxGroupCtl
+// NewTCPMuxGroupCtl returns a new TCPMuxGroupCtl.
func NewTCPMuxGroupCtl(tcpMuxHTTPConnectMuxer *tcpmux.HTTPConnectTCPMuxer) *TCPMuxGroupCtl {
return &TCPMuxGroupCtl{
- groups: make(map[string]*TCPMuxGroup),
+ groupRegistry: newGroupRegistry[*TCPMuxGroup](),
tcpMuxHTTPConnectMuxer: tcpMuxHTTPConnectMuxer,
}
}
-// Listen is the wrapper for TCPMuxGroup's Listen
-// If there are no group, we will create one here
+// Listen is the wrapper for TCPMuxGroup's Listen.
+// If there is no group, one will be created.
func (tmgc *TCPMuxGroupCtl) Listen(
ctx context.Context,
multiplexer, group, groupKey string,
routeConfig vhost.RouteConfig,
) (l net.Listener, err error) {
- tmgc.mu.Lock()
- tcpMuxGroup, ok := tmgc.groups[group]
- if !ok {
- tcpMuxGroup = NewTCPMuxGroup(tmgc)
- tmgc.groups[group] = tcpMuxGroup
- }
- tmgc.mu.Unlock()
+ for {
+ tcpMuxGroup := tmgc.getOrCreate(group, func() *TCPMuxGroup {
+ return NewTCPMuxGroup(tmgc)
+ })
- switch v1.TCPMultiplexerType(multiplexer) {
- case v1.TCPMultiplexerHTTPConnect:
- return tcpMuxGroup.HTTPConnectListen(ctx, group, groupKey, routeConfig)
- default:
- err = fmt.Errorf("unknown multiplexer [%s]", multiplexer)
- return
+ switch v1.TCPMultiplexerType(multiplexer) {
+ case v1.TCPMultiplexerHTTPConnect:
+ l, err = tcpMuxGroup.HTTPConnectListen(ctx, group, groupKey, routeConfig)
+ if err == errGroupStale {
+ continue
+ }
+ return
+ default:
+ return nil, fmt.Errorf("unknown multiplexer [%s]", multiplexer)
+ }
}
}
-// RemoveGroup remove TCPMuxGroup from controller
-func (tmgc *TCPMuxGroupCtl) RemoveGroup(group string) {
- tmgc.mu.Lock()
- defer tmgc.mu.Unlock()
- delete(tmgc.groups, group)
-}
-
-// TCPMuxGroup route connections to different proxies
+// TCPMuxGroup routes connections to different proxies.
type TCPMuxGroup struct {
- group string
- groupKey string
+ baseGroup
+
domain string
routeByHTTPUser string
username string
password string
-
- acceptCh chan net.Conn
- tcpMuxLn net.Listener
- lns []*TCPMuxGroupListener
- ctl *TCPMuxGroupCtl
- mu sync.Mutex
+ ctl *TCPMuxGroupCtl
}
-// NewTCPMuxGroup return a new TCPMuxGroup
+// NewTCPMuxGroup returns a new TCPMuxGroup.
func NewTCPMuxGroup(ctl *TCPMuxGroupCtl) *TCPMuxGroup {
return &TCPMuxGroup{
- lns: make([]*TCPMuxGroupListener, 0),
- ctl: ctl,
- acceptCh: make(chan net.Conn),
+ ctl: ctl,
}
}
-// Listen will return a new TCPMuxGroupListener
-// if TCPMuxGroup already has a listener, just add a new TCPMuxGroupListener to the queues
-// otherwise, listen on the real address
+// HTTPConnectListen will return a new Listener.
+// If TCPMuxGroup already has a listener, just add a new Listener to the queues,
+// otherwise listen on the real address.
func (tmg *TCPMuxGroup) HTTPConnectListen(
ctx context.Context,
group, groupKey string,
routeConfig vhost.RouteConfig,
-) (ln *TCPMuxGroupListener, err error) {
+) (ln *Listener, err error) {
tmg.mu.Lock()
defer tmg.mu.Unlock()
+ if !tmg.ctl.isCurrent(group, func(cur *TCPMuxGroup) bool { return cur == tmg }) {
+ return nil, errGroupStale
+ }
if len(tmg.lns) == 0 {
// the first listener, listen on the real address
tcpMuxLn, errRet := tmg.ctl.tcpMuxHTTPConnectMuxer.Listen(ctx, &routeConfig)
if errRet != nil {
return nil, errRet
}
- ln = newTCPMuxGroupListener(group, tmg, tcpMuxLn.Addr())
- tmg.group = group
- tmg.groupKey = groupKey
tmg.domain = routeConfig.Domain
tmg.routeByHTTPUser = routeConfig.RouteByHTTPUser
tmg.username = routeConfig.Username
tmg.password = routeConfig.Password
- tmg.tcpMuxLn = tcpMuxLn
- tmg.lns = append(tmg.lns, ln)
- if tmg.acceptCh == nil {
- tmg.acceptCh = make(chan net.Conn)
- }
- go tmg.worker()
+ tmg.initBase(group, groupKey, tcpMuxLn, func() {
+ tmg.ctl.removeIf(tmg.group, func(cur *TCPMuxGroup) bool {
+ return cur == tmg
+ })
+ })
+ ln = tmg.newListener(tcpMuxLn.Addr())
+ go tmg.worker(tcpMuxLn, tmg.acceptCh)
} else {
// route config in the same group must be equal
if tmg.group != group || tmg.domain != routeConfig.Domain ||
@@ -141,90 +123,7 @@ func (tmg *TCPMuxGroup) HTTPConnectListen(
if tmg.groupKey != groupKey {
return nil, ErrGroupAuthFailed
}
- ln = newTCPMuxGroupListener(group, tmg, tmg.lns[0].Addr())
- tmg.lns = append(tmg.lns, ln)
+ ln = tmg.newListener(tmg.lns[0].Addr())
}
return
}
-
-// worker is called when the real TCP listener has been created
-func (tmg *TCPMuxGroup) worker() {
- for {
- c, err := tmg.tcpMuxLn.Accept()
- if err != nil {
- return
- }
- err = gerr.PanicToError(func() {
- tmg.acceptCh <- c
- })
- if err != nil {
- return
- }
- }
-}
-
-func (tmg *TCPMuxGroup) Accept() <-chan net.Conn {
- return tmg.acceptCh
-}
-
-// CloseListener remove the TCPMuxGroupListener from the TCPMuxGroup
-func (tmg *TCPMuxGroup) CloseListener(ln *TCPMuxGroupListener) {
- tmg.mu.Lock()
- defer tmg.mu.Unlock()
- for i, tmpLn := range tmg.lns {
- if tmpLn == ln {
- tmg.lns = append(tmg.lns[:i], tmg.lns[i+1:]...)
- break
- }
- }
- if len(tmg.lns) == 0 {
- close(tmg.acceptCh)
- tmg.tcpMuxLn.Close()
- tmg.ctl.RemoveGroup(tmg.group)
- }
-}
-
-// TCPMuxGroupListener
-type TCPMuxGroupListener struct {
- groupName string
- group *TCPMuxGroup
-
- addr net.Addr
- closeCh chan struct{}
-}
-
-func newTCPMuxGroupListener(name string, group *TCPMuxGroup, addr net.Addr) *TCPMuxGroupListener {
- return &TCPMuxGroupListener{
- groupName: name,
- group: group,
- addr: addr,
- closeCh: make(chan struct{}),
- }
-}
-
-// Accept will accept connections from TCPMuxGroup
-func (ln *TCPMuxGroupListener) Accept() (c net.Conn, err error) {
- var ok bool
- select {
- case <-ln.closeCh:
- return nil, ErrListenerClosed
- case c, ok = <-ln.group.Accept():
- if !ok {
- return nil, ErrListenerClosed
- }
- return c, nil
- }
-}
-
-func (ln *TCPMuxGroupListener) Addr() net.Addr {
- return ln.addr
-}
-
-// Close close the listener
-func (ln *TCPMuxGroupListener) Close() (err error) {
- close(ln.closeCh)
-
- // remove self from TcpMuxGroup
- ln.group.CloseListener(ln)
- return
-}
diff --git a/server/api/controller.go b/server/http/controller.go
similarity index 66%
rename from server/api/controller.go
rename to server/http/controller.go
index 8c9827d8..a1842788 100644
--- a/server/api/controller.go
+++ b/server/http/controller.go
@@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package api
+package http
import (
"cmp"
- "encoding/json"
"fmt"
"net/http"
"slices"
@@ -29,6 +28,7 @@ import (
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
+ "github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
@@ -59,7 +59,7 @@ func NewController(
// /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
serverStats := mem.StatsCollector.GetServer()
- svrResp := ServerInfoResp{
+ svrResp := model.ServerInfoResp{
Version: version.Full(),
BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -80,22 +80,6 @@ func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts,
}
- // For API that returns struct, we can just return it.
- // But current GeneralResponse.Msg in legacy code expects a JSON string.
- // Since MakeHTTPHandlerFunc handles struct by encoding to JSON, we can return svrResp directly?
- // The original code wraps it in GeneralResponse{Msg: string(json)}.
- // If we return svrResp, the response body will be the JSON of svrResp.
- // We should check if the frontend expects { "code": 200, "msg": "{...}" } or just {...}.
- // Looking at previous code:
- // res := GeneralResponse{Code: 200}
- // buf, _ := json.Marshal(&svrResp)
- // res.Msg = string(buf)
- // Response body: {"code": 200, "msg": "{\"version\":...}"}
- // Wait, is it double encoded JSON? Yes it seems so!
- // Let's check dashboard_api.go original code again.
- // Yes: res.Msg = string(buf).
- // So the frontend expects { "code": 200, "msg": "JSON_STRING" }.
- // This is kind of ugly, but we must preserve compatibility.
return svrResp, nil
}
@@ -112,7 +96,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
statusFilter := strings.ToLower(ctx.Query("status"))
records := c.clientRegistry.List()
- items := make([]ClientInfoResp, 0, len(records))
+ items := make([]model.ClientInfoResp, 0, len(records))
for _, info := range records {
if userFilter != "" && info.User != userFilter {
continue
@@ -129,7 +113,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
items = append(items, buildClientInfoResp(info))
}
- slices.SortFunc(items, func(a, b ClientInfoResp) int {
+ slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
@@ -165,9 +149,9 @@ func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type")
- proxyInfoResp := GetProxyInfoResp{}
+ proxyInfoResp := model.GetProxyInfoResp{}
proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)
- slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
+ slices.SortFunc(proxyInfoResp.Proxies, func(a, b *model.ProxyStatsInfo) int {
return cmp.Compare(a.Name, b.Name)
})
@@ -191,7 +175,7 @@ func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
- trafficResp := GetProxyTrafficResp{}
+ trafficResp := model.GetProxyTrafficResp{}
trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
@@ -213,7 +197,7 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
- proxyInfo := GetProxyStatsResp{
+ proxyInfo := model.GetProxyStatsResp{
Name: ps.Name,
User: ps.User,
ClientID: ps.ClientID,
@@ -225,20 +209,8 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
}
if pxy, ok := c.pxyManager.GetByName(name); ok {
- content, err := json.Marshal(pxy.GetConfigurer())
- if err != nil {
- log.Warnf("marshal proxy [%s] conf info error: %v", name, err)
- return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
- }
- proxyInfo.Conf = getConfByType(ps.Type)
- if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
- log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err)
- return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
- }
+ proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
- c.fillProxyClientInfo(&proxyClientInfo{
- clientVersion: &proxyInfo.ClientVersion,
- }, pxy)
} else {
proxyInfo.Status = "offline"
}
@@ -254,32 +226,20 @@ func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
}
cleared, total := mem.StatsCollector.ClearOfflineProxies()
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
- return nil, nil
+ return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil
}
-func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
+func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.ProxyStatsInfo) {
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
- proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
+ proxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats {
- proxyInfo := &ProxyStatsInfo{
+ proxyInfo := &model.ProxyStatsInfo{
User: ps.User,
ClientID: ps.ClientID,
}
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
- content, err := json.Marshal(pxy.GetConfigurer())
- if err != nil {
- log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
- continue
- }
- proxyInfo.Conf = getConfByType(ps.Type)
- if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
- log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
- continue
- }
+ proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
- c.fillProxyClientInfo(&proxyClientInfo{
- clientVersion: &proxyInfo.ClientVersion,
- }, pxy)
} else {
proxyInfo.Status = "offline"
}
@@ -294,7 +254,7 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
return
}
-func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
+func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo model.GetProxyStatsResp, code int, msg string) {
proxyInfo.Name = proxyName
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
if ps == nil {
@@ -304,20 +264,7 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
proxyInfo.User = ps.User
proxyInfo.ClientID = ps.ClientID
if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
- content, err := json.Marshal(pxy.GetConfigurer())
- if err != nil {
- log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
- code = 400
- msg = "parse conf error"
- return
- }
- proxyInfo.Conf = getConfByType(ps.Type)
- if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
- log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
- code = 400
- msg = "parse conf error"
- return
- }
+ proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
@@ -333,12 +280,13 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
return
}
-func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
- resp := ClientInfoResp{
+func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp {
+ resp := model.ClientInfoResp{
Key: info.Key,
User: info.User,
ClientID: info.ClientID(),
RunID: info.RunID,
+ Version: info.Version,
Hostname: info.Hostname,
ClientIP: info.IP,
FirstConnectedAt: toUnix(info.FirstConnectedAt),
@@ -351,37 +299,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
@@ -402,23 +319,37 @@ func matchStatusFilter(online bool, filter string) bool {
}
}
-func getConfByType(proxyType string) any {
- switch v1.ProxyType(proxyType) {
- case v1.ProxyTypeTCP:
- return &TCPOutConf{}
- case v1.ProxyTypeTCPMUX:
- return &TCPMuxOutConf{}
- case v1.ProxyTypeUDP:
- return &UDPOutConf{}
- case v1.ProxyTypeHTTP:
- return &HTTPOutConf{}
- case v1.ProxyTypeHTTPS:
- return &HTTPSOutConf{}
- case v1.ProxyTypeSTCP:
- return &STCPOutConf{}
- case v1.ProxyTypeXTCP:
- return &XTCPOutConf{}
- default:
- return nil
+func getConfFromConfigurer(cfg v1.ProxyConfigurer) any {
+ outBase := model.BaseOutConf{ProxyBaseConfig: *cfg.GetBaseConfig()}
+
+ switch c := cfg.(type) {
+ case *v1.TCPProxyConfig:
+ return &model.TCPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
+ case *v1.UDPProxyConfig:
+ return &model.UDPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
+ case *v1.HTTPProxyConfig:
+ return &model.HTTPOutConf{
+ BaseOutConf: outBase,
+ DomainConfig: c.DomainConfig,
+ Locations: c.Locations,
+ HostHeaderRewrite: c.HostHeaderRewrite,
+ }
+ case *v1.HTTPSProxyConfig:
+ return &model.HTTPSOutConf{
+ BaseOutConf: outBase,
+ DomainConfig: c.DomainConfig,
+ }
+ case *v1.TCPMuxProxyConfig:
+ return &model.TCPMuxOutConf{
+ BaseOutConf: outBase,
+ DomainConfig: c.DomainConfig,
+ Multiplexer: c.Multiplexer,
+ RouteByHTTPUser: c.RouteByHTTPUser,
+ }
+ case *v1.STCPProxyConfig:
+ return &model.STCPOutConf{BaseOutConf: outBase}
+ case *v1.XTCPProxyConfig:
+ return &model.XTCPOutConf{BaseOutConf: outBase}
}
+ return outBase
}
diff --git a/server/http/controller_test.go b/server/http/controller_test.go
new file mode 100644
index 00000000..3ef50776
--- /dev/null
+++ b/server/http/controller_test.go
@@ -0,0 +1,71 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package http
+
+import (
+ "encoding/json"
+ "testing"
+
+ v1 "github.com/fatedier/frp/pkg/config/v1"
+)
+
+func TestGetConfFromConfigurerKeepsPluginFields(t *testing.T) {
+ cfg := &v1.TCPProxyConfig{
+ ProxyBaseConfig: v1.ProxyBaseConfig{
+ Name: "test-proxy",
+ Type: string(v1.ProxyTypeTCP),
+ ProxyBackend: v1.ProxyBackend{
+ Plugin: v1.TypedClientPluginOptions{
+ Type: v1.PluginHTTPProxy,
+ ClientPluginOptions: &v1.HTTPProxyPluginOptions{
+ Type: v1.PluginHTTPProxy,
+ HTTPUser: "user",
+ HTTPPassword: "password",
+ },
+ },
+ },
+ },
+ RemotePort: 6000,
+ }
+
+ content, err := json.Marshal(getConfFromConfigurer(cfg))
+ if err != nil {
+ t.Fatalf("marshal conf failed: %v", err)
+ }
+
+ var out map[string]any
+ if err := json.Unmarshal(content, &out); err != nil {
+ t.Fatalf("unmarshal conf failed: %v", err)
+ }
+
+ pluginValue, ok := out["plugin"]
+ if !ok {
+ t.Fatalf("plugin field missing in output: %v", out)
+ }
+ plugin, ok := pluginValue.(map[string]any)
+ if !ok {
+ t.Fatalf("plugin field should be object, got: %#v", pluginValue)
+ }
+
+ if got := plugin["type"]; got != v1.PluginHTTPProxy {
+ t.Fatalf("plugin type mismatch, want %q got %#v", v1.PluginHTTPProxy, got)
+ }
+ if got := plugin["httpUser"]; got != "user" {
+ t.Fatalf("plugin httpUser mismatch, want %q got %#v", "user", got)
+ }
+ if got := plugin["httpPassword"]; got != "password" {
+ t.Fatalf("plugin httpPassword mismatch, want %q got %#v", "password", got)
+ }
+}
diff --git a/server/api/types.go b/server/http/model/types.go
similarity index 97%
rename from server/api/types.go
rename to server/http/model/types.go
index b91422be..92467e4b 100644
--- a/server/api/types.go
+++ b/server/http/model/types.go
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package api
+package model
import (
v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -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"`
diff --git a/server/proxy/http.go b/server/proxy/http.go
index 2c4f1fd4..05afc2e9 100644
--- a/server/proxy/http.go
+++ b/server/proxy/http.go
@@ -31,7 +31,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.HTTPProxyConfig{}), NewHTTPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.HTTPProxyConfig](), NewHTTPProxy)
}
type HTTPProxy struct {
@@ -75,16 +75,13 @@ func (pxy *HTTPProxy) Run() (remoteAddr string, err error) {
}
}()
- addrs := make([]string, 0)
- for _, domain := range pxy.cfg.CustomDomains {
- if domain == "" {
- continue
- }
+ domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)
+ addrs := make([]string, 0)
+ for _, domain := range domains {
routeConfig.Domain = domain
for _, location := range locations {
routeConfig.Location = location
-
tmpRouteConfig := routeConfig
// handle group
@@ -93,12 +90,10 @@ func (pxy *HTTPProxy) Run() (remoteAddr string, err error) {
if err != nil {
return
}
-
pxy.closeFuncs = append(pxy.closeFuncs, func() {
pxy.rc.HTTPGroupCtl.UnRegister(pxy.name, pxy.cfg.LoadBalancer.Group, tmpRouteConfig)
})
} else {
- // no group
err = pxy.rc.HTTPReverseProxy.Register(routeConfig)
if err != nil {
return
@@ -112,39 +107,6 @@ func (pxy *HTTPProxy) Run() (remoteAddr string, err error) {
routeConfig.Domain, routeConfig.Location, pxy.cfg.LoadBalancer.Group, pxy.cfg.RouteByHTTPUser)
}
}
-
- if pxy.cfg.SubDomain != "" {
- routeConfig.Domain = pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost
- for _, location := range locations {
- routeConfig.Location = location
-
- tmpRouteConfig := routeConfig
-
- // handle group
- if pxy.cfg.LoadBalancer.Group != "" {
- err = pxy.rc.HTTPGroupCtl.Register(pxy.name, pxy.cfg.LoadBalancer.Group, pxy.cfg.LoadBalancer.GroupKey, routeConfig)
- if err != nil {
- return
- }
-
- pxy.closeFuncs = append(pxy.closeFuncs, func() {
- pxy.rc.HTTPGroupCtl.UnRegister(pxy.name, pxy.cfg.LoadBalancer.Group, tmpRouteConfig)
- })
- } else {
- err = pxy.rc.HTTPReverseProxy.Register(routeConfig)
- if err != nil {
- return
- }
- pxy.closeFuncs = append(pxy.closeFuncs, func() {
- pxy.rc.HTTPReverseProxy.UnRegister(tmpRouteConfig)
- })
- }
- addrs = append(addrs, util.CanonicalAddr(tmpRouteConfig.Domain, pxy.serverCfg.VhostHTTPPort))
-
- xl.Infof("http proxy listen for host [%s] location [%s] group [%s], routeByHTTPUser [%s]",
- routeConfig.Domain, routeConfig.Location, pxy.cfg.LoadBalancer.Group, pxy.cfg.RouteByHTTPUser)
- }
- }
remoteAddr = strings.Join(addrs, ",")
return
}
@@ -168,6 +130,7 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
if err != nil {
xl.Errorf("create encryption stream error: %v", err)
+ tmpConn.Close()
return
}
}
diff --git a/server/proxy/https.go b/server/proxy/https.go
index f137ea7a..e2efc736 100644
--- a/server/proxy/https.go
+++ b/server/proxy/https.go
@@ -25,7 +25,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.HTTPSProxyConfig{}), NewHTTPSProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.HTTPSProxyConfig](), NewHTTPSProxy)
}
type HTTPSProxy struct {
@@ -53,23 +53,10 @@ func (pxy *HTTPSProxy) Run() (remoteAddr string, err error) {
pxy.Close()
}
}()
+ domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)
+
addrs := make([]string, 0)
- for _, domain := range pxy.cfg.CustomDomains {
- if domain == "" {
- continue
- }
-
- l, err := pxy.listenForDomain(routeConfig, domain)
- if err != nil {
- return "", err
- }
- pxy.listeners = append(pxy.listeners, l)
- addrs = append(addrs, util.CanonicalAddr(domain, pxy.serverCfg.VhostHTTPSPort))
- xl.Infof("https proxy listen for host [%s] group [%s]", domain, pxy.cfg.LoadBalancer.Group)
- }
-
- if pxy.cfg.SubDomain != "" {
- domain := pxy.cfg.SubDomain + "." + pxy.serverCfg.SubDomainHost
+ for _, domain := range domains {
l, err := pxy.listenForDomain(routeConfig, domain)
if err != nil {
return "", err
diff --git a/server/proxy/proxy.go b/server/proxy/proxy.go
index 564eca28..5b7898eb 100644
--- a/server/proxy/proxy.go
+++ b/server/proxy/proxy.go
@@ -150,7 +150,7 @@ func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn,
dstAddr, dstPortStr, _ = net.SplitHostPort(dst.String())
dstPort, _ = strconv.ParseUint(dstPortStr, 10, 16)
}
- err := msg.WriteMsg(workConn, &msg.StartWorkConn{
+ err = msg.WriteMsg(workConn, &msg.StartWorkConn{
ProxyName: pxy.GetName(),
SrcAddr: srcAddr,
SrcPort: uint16(srcPort),
@@ -161,6 +161,7 @@ func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn,
if err != nil {
xl.Warnf("failed to send message to work connection from pool: %v, times: %d", err, i)
workConn.Close()
+ workConn = nil
} else {
break
}
@@ -173,6 +174,36 @@ func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn,
return
}
+// startVisitorListener sets up a VisitorManager listener for visitor-based proxies (STCP, SUDP).
+func (pxy *BaseProxy) startVisitorListener(secretKey string, allowUsers []string, proxyType string) error {
+ // if allowUsers is empty, only allow same user from proxy
+ if len(allowUsers) == 0 {
+ allowUsers = []string{pxy.GetUserInfo().User}
+ }
+ listener, err := pxy.rc.VisitorManager.Listen(pxy.GetName(), secretKey, allowUsers)
+ if err != nil {
+ return err
+ }
+ pxy.listeners = append(pxy.listeners, listener)
+ pxy.xl.Infof("%s proxy custom listen success", proxyType)
+ pxy.startCommonTCPListenersHandler()
+ return nil
+}
+
+// buildDomains constructs a list of domains from custom domains and subdomain configuration.
+func (pxy *BaseProxy) buildDomains(customDomains []string, subDomain string) []string {
+ domains := make([]string, 0, len(customDomains)+1)
+ for _, d := range customDomains {
+ if d != "" {
+ domains = append(domains, d)
+ }
+ }
+ if subDomain != "" {
+ domains = append(domains, subDomain+"."+pxy.serverCfg.SubDomainHost)
+ }
+ return domains
+}
+
// startCommonTCPListenersHandler start a goroutine handler for each listener.
func (pxy *BaseProxy) startCommonTCPListenersHandler() {
xl := xlog.FromContextSafe(pxy.ctx)
diff --git a/server/proxy/stcp.go b/server/proxy/stcp.go
index 06b1b17f..f8c17a46 100644
--- a/server/proxy/stcp.go
+++ b/server/proxy/stcp.go
@@ -21,7 +21,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.STCPProxyConfig{}), NewSTCPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.STCPProxyConfig](), NewSTCPProxy)
}
type STCPProxy struct {
@@ -41,21 +41,7 @@ func NewSTCPProxy(baseProxy *BaseProxy) Proxy {
}
func (pxy *STCPProxy) Run() (remoteAddr string, err error) {
- xl := pxy.xl
- allowUsers := pxy.cfg.AllowUsers
- // if allowUsers is empty, only allow same user from proxy
- if len(allowUsers) == 0 {
- allowUsers = []string{pxy.GetUserInfo().User}
- }
- listener, errRet := pxy.rc.VisitorManager.Listen(pxy.GetName(), pxy.cfg.Secretkey, allowUsers)
- if errRet != nil {
- err = errRet
- return
- }
- pxy.listeners = append(pxy.listeners, listener)
- xl.Infof("stcp proxy custom listen success")
-
- pxy.startCommonTCPListenersHandler()
+ err = pxy.startVisitorListener(pxy.cfg.Secretkey, pxy.cfg.AllowUsers, "stcp")
return
}
diff --git a/server/proxy/sudp.go b/server/proxy/sudp.go
index f37fb423..d409fed6 100644
--- a/server/proxy/sudp.go
+++ b/server/proxy/sudp.go
@@ -21,7 +21,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.SUDPProxyConfig{}), NewSUDPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.SUDPProxyConfig](), NewSUDPProxy)
}
type SUDPProxy struct {
@@ -41,21 +41,7 @@ func NewSUDPProxy(baseProxy *BaseProxy) Proxy {
}
func (pxy *SUDPProxy) Run() (remoteAddr string, err error) {
- xl := pxy.xl
- allowUsers := pxy.cfg.AllowUsers
- // if allowUsers is empty, only allow same user from proxy
- if len(allowUsers) == 0 {
- allowUsers = []string{pxy.GetUserInfo().User}
- }
- listener, errRet := pxy.rc.VisitorManager.Listen(pxy.GetName(), pxy.cfg.Secretkey, allowUsers)
- if errRet != nil {
- err = errRet
- return
- }
- pxy.listeners = append(pxy.listeners, listener)
- xl.Infof("sudp proxy custom listen success")
-
- pxy.startCommonTCPListenersHandler()
+ err = pxy.startVisitorListener(pxy.cfg.Secretkey, pxy.cfg.AllowUsers, "sudp")
return
}
diff --git a/server/proxy/tcp.go b/server/proxy/tcp.go
index a6eae3a9..c305e779 100644
--- a/server/proxy/tcp.go
+++ b/server/proxy/tcp.go
@@ -24,7 +24,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.TCPProxyConfig{}), NewTCPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.TCPProxyConfig](), NewTCPProxy)
}
type TCPProxy struct {
diff --git a/server/proxy/tcpmux.go b/server/proxy/tcpmux.go
index 6e95b66b..0c421c8e 100644
--- a/server/proxy/tcpmux.go
+++ b/server/proxy/tcpmux.go
@@ -26,7 +26,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.TCPMuxProxyConfig{}), NewTCPMuxProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.TCPMuxProxyConfig](), NewTCPMuxProxy)
}
type TCPMuxProxy struct {
@@ -72,26 +72,16 @@ func (pxy *TCPMuxProxy) httpConnectListen(
}
func (pxy *TCPMuxProxy) httpConnectRun() (remoteAddr string, err error) {
+ domains := pxy.buildDomains(pxy.cfg.CustomDomains, pxy.cfg.SubDomain)
+
addrs := make([]string, 0)
- for _, domain := range pxy.cfg.CustomDomains {
- if domain == "" {
- continue
- }
-
+ for _, domain := range domains {
addrs, err = pxy.httpConnectListen(domain, pxy.cfg.RouteByHTTPUser, pxy.cfg.HTTPUser, pxy.cfg.HTTPPassword, addrs)
if err != nil {
return "", err
}
}
- if pxy.cfg.SubDomain != "" {
- addrs, err = pxy.httpConnectListen(pxy.cfg.SubDomain+"."+pxy.serverCfg.SubDomainHost,
- pxy.cfg.RouteByHTTPUser, pxy.cfg.HTTPUser, pxy.cfg.HTTPPassword, addrs)
- if err != nil {
- return "", err
- }
- }
-
pxy.startCommonTCPListenersHandler()
remoteAddr = strings.Join(addrs, ",")
return remoteAddr, err
diff --git a/server/proxy/udp.go b/server/proxy/udp.go
index 3751dc9b..6c5b854a 100644
--- a/server/proxy/udp.go
+++ b/server/proxy/udp.go
@@ -35,7 +35,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.UDPProxyConfig{}), NewUDPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.UDPProxyConfig](), NewUDPProxy)
}
type UDPProxy struct {
@@ -136,7 +136,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
continue
case *msg.UDPPacket:
if errRet := errors.PanicToError(func() {
- xl.Tracef("get udp message from workConn: %s", m.Content)
+ xl.Tracef("get udp message from workConn, len: %d", len(m.Content))
pxy.readCh <- m
metrics.Server.AddTrafficOut(
pxy.GetName(),
@@ -167,7 +167,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
conn.Close()
return
}
- xl.Tracef("send message to udp workConn: %s", udpMsg.Content)
+ xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
metrics.Server.AddTrafficIn(
pxy.GetName(),
pxy.GetConfigurer().GetBaseConfig().Type,
diff --git a/server/proxy/xtcp.go b/server/proxy/xtcp.go
index 1ccf331c..bef7320e 100644
--- a/server/proxy/xtcp.go
+++ b/server/proxy/xtcp.go
@@ -24,7 +24,7 @@ import (
)
func init() {
- RegisterProxyFactory(reflect.TypeOf(&v1.XTCPProxyConfig{}), NewXTCPProxy)
+ RegisterProxyFactory(reflect.TypeFor[*v1.XTCPProxyConfig](), NewXTCPProxy)
}
type XTCPProxy struct {
diff --git a/server/registry/registry.go b/server/registry/registry.go
index 24751bf6..01c44947 100644
--- a/server/registry/registry.go
+++ b/server/registry/registry.go
@@ -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 == "":
diff --git a/server/service.go b/server/service.go
index 6106dad6..28ccb451 100644
--- a/server/service.go
+++ b/server/service.go
@@ -28,7 +28,6 @@ import (
"github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux"
fmux "github.com/hashicorp/yamux"
- "github.com/prometheus/client_golang/prometheus/promhttp"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
@@ -48,7 +47,6 @@ import (
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/pkg/util/vhost"
"github.com/fatedier/frp/pkg/util/xlog"
- "github.com/fatedier/frp/server/api"
"github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/group"
"github.com/fatedier/frp/server/metrics"
@@ -195,7 +193,7 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
if err != nil {
return nil, fmt.Errorf("create vhost tcpMuxer error, %v", err)
}
- log.Infof("tcpmux httpconnect multiplexer listen on %s, passthough: %v", address, cfg.TCPMuxPassthrough)
+ log.Infof("tcpmux httpconnect multiplexer listen on %s, passthrough: %v", address, cfg.TCPMuxPassthrough)
}
// Init all plugins
@@ -606,8 +604,18 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
return err
}
- // TODO(fatedier): use SessionContext
- ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, authVerifier, svr.auth.EncryptionKey(), ctlConn, !internal, loginMsg, svr.cfg)
+ ctl, err := NewControl(ctx, &SessionContext{
+ RC: svr.rc,
+ PxyManager: svr.pxyManager,
+ PluginManager: svr.pluginManager,
+ AuthVerifier: authVerifier,
+ EncryptionKey: svr.auth.EncryptionKey(),
+ Conn: ctlConn,
+ ConnEncrypted: !internal,
+ LoginMsg: loginMsg,
+ ServerCfg: svr.cfg,
+ ClientRegistry: svr.clientRegistry,
+ })
if err != nil {
xl.Warnf("create new controller error: %v", err)
// don't return detailed errors to client
@@ -622,13 +630,12 @@ 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()
return fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
}
- ctl.clientRegistry = svr.clientRegistry
ctl.Start()
@@ -654,9 +661,9 @@ func (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn)
// server plugin hook
content := &plugin.NewWorkConnContent{
User: plugin.UserInfo{
- User: ctl.loginMsg.User,
- Metas: ctl.loginMsg.Metas,
- RunID: ctl.loginMsg.RunID,
+ User: ctl.sessionCtx.LoginMsg.User,
+ Metas: ctl.sessionCtx.LoginMsg.Metas,
+ RunID: ctl.sessionCtx.LoginMsg.RunID,
},
NewWorkConn: *newMsg,
}
@@ -664,7 +671,7 @@ func (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn)
if err == nil {
newMsg = &retContent.NewWorkConn
// Check auth.
- err = ctl.authVerifier.VerifyNewWorkConn(newMsg)
+ err = ctl.sessionCtx.AuthVerifier.VerifyNewWorkConn(newMsg)
}
if err != nil {
xl.Warnf("invalid NewWorkConn with run id [%s]", newMsg.RunID)
@@ -685,47 +692,8 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
if !exist {
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
}
- visitorUser = ctl.loginMsg.User
+ visitorUser = ctl.sessionCtx.LoginMsg.User
}
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
}
-
-func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
- helper.Router.HandleFunc("/healthz", healthz)
- subRouter := helper.Router.NewRoute().Subrouter()
-
- subRouter.Use(helper.AuthMiddleware)
- subRouter.Use(httppkg.NewRequestLogger)
-
- // metrics
- if svr.cfg.EnablePrometheus {
- subRouter.Handle("/metrics", promhttp.Handler())
- }
-
- apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
-
- // apis
- subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
- subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
- subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
- subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
- subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
- subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
- subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
- subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
-
- // view
- subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
- subRouter.PathPrefix("/static/").Handler(
- netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
- ).Methods("GET")
-
- subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
- })
-}
-
-func healthz(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(200)
-}
diff --git a/test/e2e/examples.go b/test/e2e/examples.go
index 135ce706..9f0bc335 100644
--- a/test/e2e/examples.go
+++ b/test/e2e/examples.go
@@ -26,7 +26,7 @@ var _ = ginkgo.Describe("[Feature: Example]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
diff --git a/test/e2e/framework/process.go b/test/e2e/framework/process.go
index 0b837e39..f5faa637 100644
--- a/test/e2e/framework/process.go
+++ b/test/e2e/framework/process.go
@@ -2,70 +2,85 @@ package framework
import (
"fmt"
+ "maps"
+ "net"
"os"
"path/filepath"
+ "strconv"
"time"
+ "github.com/fatedier/frp/pkg/config"
flog "github.com/fatedier/frp/pkg/util/log"
+ "github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/pkg/process"
)
-// RunProcesses run multiple processes from templates.
-// The first template should always be frps.
-func (f *Framework) RunProcesses(serverTemplates []string, clientTemplates []string) ([]*process.Process, []*process.Process) {
- templates := make([]string, 0, len(serverTemplates)+len(clientTemplates))
- templates = append(templates, serverTemplates...)
- templates = append(templates, clientTemplates...)
+// RunProcesses starts one frps and zero or more frpc processes from templates.
+func (f *Framework) RunProcesses(serverTemplate string, clientTemplates []string) (*process.Process, []*process.Process) {
+ templates := append([]string{serverTemplate}, clientTemplates...)
outs, ports, err := f.RenderTemplates(templates)
ExpectNoError(err)
- ExpectTrue(len(templates) > 0)
- for name, port := range ports {
- f.usedPorts[name] = port
+ maps.Copy(f.usedPorts, ports)
+
+ // Start frps.
+ serverPath := filepath.Join(f.TempDirectory, "frp-e2e-server-0")
+ err = os.WriteFile(serverPath, []byte(outs[0]), 0o600)
+ ExpectNoError(err)
+
+ if TestContext.Debug {
+ flog.Debugf("[%s] %s", serverPath, outs[0])
}
- currentServerProcesses := make([]*process.Process, 0, len(serverTemplates))
- for i := range serverTemplates {
- path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-server-%d", i))
- err = os.WriteFile(path, []byte(outs[i]), 0o600)
- ExpectNoError(err)
+ serverProcess := process.NewWithEnvs(TestContext.FRPServerPath, []string{"-c", serverPath}, f.osEnvs)
+ f.serverConfPaths = append(f.serverConfPaths, serverPath)
+ f.serverProcesses = append(f.serverProcesses, serverProcess)
+ err = serverProcess.Start()
+ ExpectNoError(err)
- if TestContext.Debug {
- flog.Debugf("[%s] %s", path, outs[i])
- }
-
- p := process.NewWithEnvs(TestContext.FRPServerPath, []string{"-c", path}, f.osEnvs)
- f.serverConfPaths = append(f.serverConfPaths, path)
- f.serverProcesses = append(f.serverProcesses, p)
- currentServerProcesses = append(currentServerProcesses, p)
- err = p.Start()
- ExpectNoError(err)
- time.Sleep(500 * time.Millisecond)
+ if port, ok := ports[consts.PortServerName]; ok {
+ ExpectNoError(WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 5*time.Second))
+ } else {
+ time.Sleep(2 * time.Second)
}
- time.Sleep(2 * time.Second)
- currentClientProcesses := make([]*process.Process, 0, len(clientTemplates))
+ // Start frpc(s).
+ clientProcesses := make([]*process.Process, 0, len(clientTemplates))
for i := range clientTemplates {
- index := i + len(serverTemplates)
path := filepath.Join(f.TempDirectory, fmt.Sprintf("frp-e2e-client-%d", i))
- err = os.WriteFile(path, []byte(outs[index]), 0o600)
+ err = os.WriteFile(path, []byte(outs[1+i]), 0o600)
ExpectNoError(err)
if TestContext.Debug {
- flog.Debugf("[%s] %s", path, outs[index])
+ flog.Debugf("[%s] %s", path, outs[1+i])
}
p := process.NewWithEnvs(TestContext.FRPClientPath, []string{"-c", path}, f.osEnvs)
f.clientConfPaths = append(f.clientConfPaths, path)
f.clientProcesses = append(f.clientProcesses, p)
- currentClientProcesses = append(currentClientProcesses, p)
+ clientProcesses = append(clientProcesses, p)
err = p.Start()
ExpectNoError(err)
- time.Sleep(500 * time.Millisecond)
}
- time.Sleep(3 * time.Second)
+ // Wait for each client's proxies to register with frps.
+ // If any client has no proxies (e.g. visitor-only), fall back to sleep
+ // for the remaining time since visitors have no deterministic readiness signal.
+ allConfirmed := len(clientProcesses) > 0
+ start := time.Now()
+ for i, p := range clientProcesses {
+ configPath := f.clientConfPaths[len(f.clientConfPaths)-len(clientProcesses)+i]
+ if !waitForClientProxyReady(configPath, p, 5*time.Second) {
+ allConfirmed = false
+ }
+ }
+ if len(clientProcesses) > 0 && !allConfirmed {
+ remaining := 1500*time.Millisecond - time.Since(start)
+ if remaining > 0 {
+ time.Sleep(remaining)
+ }
+ }
- return currentServerProcesses, currentClientProcesses
+ return serverProcess, clientProcesses
}
func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
@@ -73,11 +88,13 @@ func (f *Framework) RunFrps(args ...string) (*process.Process, string, error) {
f.serverProcesses = append(f.serverProcesses, p)
err := p.Start()
if err != nil {
- return p, p.StdOutput(), err
+ return p, p.Output(), err
}
- // Give frps extra time to finish binding ports before proceeding.
- time.Sleep(4 * time.Second)
- return p, p.StdOutput(), nil
+ select {
+ case <-p.Done():
+ case <-time.After(2 * time.Second):
+ }
+ return p, p.Output(), nil
}
func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
@@ -85,10 +102,13 @@ func (f *Framework) RunFrpc(args ...string) (*process.Process, string, error) {
f.clientProcesses = append(f.clientProcesses, p)
err := p.Start()
if err != nil {
- return p, p.StdOutput(), err
+ return p, p.Output(), err
}
- time.Sleep(2 * time.Second)
- return p, p.StdOutput(), nil
+ select {
+ case <-p.Done():
+ case <-time.After(1500 * time.Millisecond):
+ }
+ return p, p.Output(), nil
}
func (f *Framework) GenerateConfigFile(content string) string {
@@ -98,3 +118,74 @@ func (f *Framework) GenerateConfigFile(content string) string {
ExpectNoError(err)
return path
}
+
+// waitForClientProxyReady parses the client config to extract proxy names,
+// then waits for each proxy's "start proxy success" log in the process output.
+// Returns true only if proxies were expected and all registered successfully.
+func waitForClientProxyReady(configPath string, p *process.Process, timeout time.Duration) bool {
+ _, proxyCfgs, _, _, err := config.LoadClientConfig(configPath, false)
+ if err != nil || len(proxyCfgs) == 0 {
+ return false
+ }
+
+ // Use a single deadline so the total wait across all proxies does not exceed timeout.
+ deadline := time.Now().Add(timeout)
+ for _, cfg := range proxyCfgs {
+ remaining := time.Until(deadline)
+ if remaining <= 0 {
+ return false
+ }
+ name := cfg.GetBaseConfig().Name
+ pattern := fmt.Sprintf("[%s] start proxy success", name)
+ if err := p.WaitForOutput(pattern, 1, remaining); err != nil {
+ return false
+ }
+ }
+ return true
+}
+
+// WaitForTCPUnreachable polls a TCP address until a connection fails or timeout.
+func WaitForTCPUnreachable(addr string, interval, timeout time.Duration) error {
+ if interval <= 0 {
+ return fmt.Errorf("invalid interval for TCP unreachable on %s: interval must be positive", addr)
+ }
+ if timeout <= 0 {
+ return fmt.Errorf("invalid timeout for TCP unreachable on %s: timeout must be positive", addr)
+ }
+ deadline := time.Now().Add(timeout)
+ for {
+ remaining := time.Until(deadline)
+ if remaining <= 0 {
+ return fmt.Errorf("timeout waiting for TCP unreachable on %s", addr)
+ }
+ dialTimeout := min(interval, remaining)
+ conn, err := net.DialTimeout("tcp", addr, dialTimeout)
+ if err != nil {
+ return nil
+ }
+ conn.Close()
+ time.Sleep(min(interval, time.Until(deadline)))
+ }
+}
+
+// WaitForTCPReady polls a TCP address until a connection succeeds or timeout.
+func WaitForTCPReady(addr string, timeout time.Duration) error {
+ if timeout <= 0 {
+ return fmt.Errorf("invalid timeout for TCP readiness on %s: timeout must be positive", addr)
+ }
+ deadline := time.Now().Add(timeout)
+ var lastErr error
+ for time.Now().Before(deadline) {
+ conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
+ if err == nil {
+ conn.Close()
+ return nil
+ }
+ lastErr = err
+ time.Sleep(50 * time.Millisecond)
+ }
+ if lastErr == nil {
+ return fmt.Errorf("timeout waiting for TCP readiness on %s before any dial attempt", addr)
+ }
+ return fmt.Errorf("timeout waiting for TCP readiness on %s: %w", addr, lastErr)
+}
diff --git a/test/e2e/legacy/basic/basic.go b/test/e2e/legacy/basic/basic.go
index 763d3353..0115c6cb 100644
--- a/test/e2e/legacy/basic/basic.go
+++ b/test/e2e/legacy/basic/basic.go
@@ -26,7 +26,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
proxyType := t
ginkgo.It(fmt.Sprintf("Expose a %s echo server", strings.ToUpper(proxyType)), func() {
serverConf := consts.LegacyDefaultServerConfig
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
localPortName := ""
protocol := "tcp"
@@ -78,10 +79,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
framework.NewRequestExpect(f).
@@ -102,7 +103,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
vhost_http_port = %d
`, vhostHTTPPort)
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
getProxyConf := func(proxyName string, customDomains string, extra string) string {
return fmt.Sprintf(`
@@ -147,13 +149,13 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
if tests[i].customDomains == "" {
tests[i].customDomains = test.proxyName + ".example.com"
}
- clientConf += getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
- for _, domain := range strings.Split(test.customDomains, ",") {
+ for domain := range strings.SplitSeq(test.customDomains, ",") {
domain = strings.TrimSpace(domain)
framework.NewRequestExpect(f).
Explain(test.proxyName + "-" + domain).
@@ -185,7 +187,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
`, vhostHTTPSPort)
localPort := f.AllocPort()
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
getProxyConf := func(proxyName string, customDomains string, extra string) string {
return fmt.Sprintf(`
[%s]
@@ -229,10 +232,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
if tests[i].customDomains == "" {
tests[i].customDomains = test.proxyName + ".example.com"
}
- clientConf += getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -244,7 +247,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
f.RunServer("", localServer)
for _, test := range tests {
- for _, domain := range strings.Split(test.customDomains, ",") {
+ for domain := range strings.SplitSeq(test.customDomains, ",") {
domain = strings.TrimSpace(domain)
framework.NewRequestExpect(f).
Explain(test.proxyName + "-" + domain).
@@ -282,9 +285,12 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
proxyType := t
ginkgo.It(fmt.Sprintf("Expose echo server with %s", strings.ToUpper(proxyType)), func() {
serverConf := consts.LegacyDefaultServerConfig
- clientServerConf := consts.LegacyDefaultClientConfig + "\nuser = user1"
- clientVisitorConf := consts.LegacyDefaultClientConfig + "\nuser = user1"
- clientUser2VisitorConf := consts.LegacyDefaultClientConfig + "\nuser = user2"
+ var clientServerConf strings.Builder
+ clientServerConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user1")
+ var clientVisitorConf strings.Builder
+ clientVisitorConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user1")
+ var clientUser2VisitorConf strings.Builder
+ clientUser2VisitorConf.WriteString(consts.LegacyDefaultClientConfig + "\nuser = user2")
localPortName := ""
protocol := "tcp"
@@ -400,20 +406,20 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientServerConf += getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n"
+ clientServerConf.WriteString(getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n")
}
for _, test := range tests {
config := getProxyVisitorConf(
test.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+"\n"+test.visitorExtraConfig,
) + "\n"
if test.deployUser2Client {
- clientUser2VisitorConf += config
+ clientUser2VisitorConf.WriteString(config)
} else {
- clientVisitorConf += config
+ clientVisitorConf.WriteString(config)
}
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientServerConf, clientVisitorConf, clientUser2VisitorConf})
+ f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
for _, test := range tests {
timeout := time.Second
@@ -440,7 +446,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
ginkgo.Describe("TCPMUX", func() {
ginkgo.It("Type tcpmux", func() {
serverConf := consts.LegacyDefaultServerConfig
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
tcpmuxHTTPConnectPortName := port.GenName("TCPMUX")
serverConf += fmt.Sprintf(`
@@ -483,14 +490,14 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.extraConfig) + "\n")
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(f.AllocPort()), streamserver.WithRespContent([]byte(test.proxyName)))
f.RunServer(port.GenName(test.proxyName), localServer)
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
// Request without HTTP connect should get error
framework.NewRequestExpect(f).
diff --git a/test/e2e/legacy/basic/client.go b/test/e2e/legacy/basic/client.go
index daed8b22..d9b9b6da 100644
--- a/test/e2e/legacy/basic/client.go
+++ b/test/e2e/legacy/basic/client.go
@@ -48,7 +48,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
framework.TCPEchoServerPort, p2Port,
framework.TCPEchoServerPort, p3Port)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure()
framework.NewRequestExpect(f).Port(p2Port).Ensure()
@@ -90,7 +90,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
admin_pwd = admin
`, dashboardPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz")
@@ -116,7 +116,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
remote_port = %d
`, adminPort, framework.TCPEchoServerPort, testPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(testPort).Ensure()
diff --git a/test/e2e/legacy/basic/client_server.go b/test/e2e/legacy/basic/client_server.go
index d85b5acc..330b9cfc 100644
--- a/test/e2e/legacy/basic/client_server.go
+++ b/test/e2e/legacy/basic/client_server.go
@@ -76,7 +76,7 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
clientConfs = append(clientConfs, client2Conf)
}
- f.RunProcesses([]string{serverConf}, clientConfs)
+ f.RunProcesses(serverConf, clientConfs)
if configures.testDelay > 0 {
time.Sleep(configures.testDelay)
diff --git a/test/e2e/legacy/basic/config.go b/test/e2e/legacy/basic/config.go
index 6e9f3564..334cc062 100644
--- a/test/e2e/legacy/basic/config.go
+++ b/test/e2e/legacy/basic/config.go
@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
`, "`", "`", framework.TCPEchoServerPort, portName)
f.SetEnvs([]string{"FRP_TOKEN=123"})
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
diff --git a/test/e2e/legacy/basic/http.go b/test/e2e/legacy/basic/http.go
index 77ba1052..f1807ecd 100644
--- a/test/e2e/legacy/basic/http.go
+++ b/test/e2e/legacy/basic/http.go
@@ -56,7 +56,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
locations = /bar
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tests := []struct {
path string
@@ -111,7 +111,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = normal.example.com
`, fooPort, barPort, otherPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// user1
framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort).
@@ -152,7 +152,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
http_pwd = test
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -188,7 +188,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = *.example.com
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not match host
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -238,7 +238,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
subdomain = bar
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// foo
framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort).
@@ -279,7 +279,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
header_X-From-Where = frp
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -312,7 +312,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
host_header_rewrite = rewrite.example.com
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -360,7 +360,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
custom_domains = 127.0.0.1
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
diff --git a/test/e2e/legacy/basic/server.go b/test/e2e/legacy/basic/server.go
index 4399439d..a01f44aa 100644
--- a/test/e2e/legacy/basic/server.go
+++ b/test/e2e/legacy/basic/server.go
@@ -28,7 +28,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
tcpPortName := port.GenName("TCP", port.WithRangePorts(10000, 11000))
udpPortName := port.GenName("UDP", port.WithRangePorts(12000, 13000))
clientConf += fmt.Sprintf(`
- [tcp-allowded-in-range]
+ [tcp-allowed-in-range]
type = tcp
local_port = {{ .%s }}
remote_port = {{ .%s }}
@@ -58,7 +58,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
remote_port = 11003
`, framework.UDPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// TCP
// Allowed in range
@@ -97,7 +97,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
local_port = {{ .%s }}
`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
client := f.APIClientForFrpc(adminPort)
@@ -138,7 +138,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
custom_domains = example.com
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("example.com")
@@ -165,7 +165,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
custom_domains = example.com
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz")
diff --git a/test/e2e/legacy/basic/tcpmux.go b/test/e2e/legacy/basic/tcpmux.go
index 15477837..3872ea04 100644
--- a/test/e2e/legacy/basic/tcpmux.go
+++ b/test/e2e/legacy/basic/tcpmux.go
@@ -76,7 +76,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
custom_domains = normal.example.com
`, fooPort, barPort, otherPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// user1
framework.NewRequestExpect(f).Explain("user1").
@@ -121,7 +121,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
http_pwd = test
`, fooPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Explain("no auth").
@@ -204,7 +204,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
custom_domains = normal.example.com
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) {
diff --git a/test/e2e/legacy/basic/xtcp.go b/test/e2e/legacy/basic/xtcp.go
index 3c47f577..6e72f2e1 100644
--- a/test/e2e/legacy/basic/xtcp.go
+++ b/test/e2e/legacy/basic/xtcp.go
@@ -41,7 +41,7 @@ var _ = ginkgo.Describe("[Feature: XTCP]", func() {
fallback_timeout_ms = 200
`, framework.TCPEchoServerPort, bindPortName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) {
r.Timeout(time.Second)
diff --git a/test/e2e/legacy/features/bandwidth_limit.go b/test/e2e/legacy/features/bandwidth_limit.go
index c94e473f..a54866b8 100644
--- a/test/e2e/legacy/features/bandwidth_limit.go
+++ b/test/e2e/legacy/features/bandwidth_limit.go
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
bandwidth_limit = 10KB
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now()
@@ -89,7 +89,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
remote_port = %d
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now()
diff --git a/test/e2e/legacy/features/group.go b/test/e2e/legacy/features/group.go
index dc15f75c..57c143c5 100644
--- a/test/e2e/legacy/features/group.go
+++ b/test/e2e/legacy/features/group.go
@@ -48,12 +48,10 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
return true
})
}
- for i := 0; i < 10; i++ {
- wait.Add(1)
- go func() {
- defer wait.Done()
+ for range 10 {
+ wait.Go(func() {
expectFn()
- }()
+ })
}
wait.Wait()
@@ -90,11 +88,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
group_key = 123
`, fooPort, remotePort, barPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
fooCount := 0
barCount := 0
- for i := 0; i < 10; i++ {
+ for i := range 10 {
framework.NewRequestExpect(f).Explain("times " + strconv.Itoa(i)).Port(remotePort).Ensure(func(resp *request.Response) bool {
switch string(resp.Content) {
case "foo":
@@ -146,11 +144,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
health_check_interval_s = 1
`, fooPort, remotePort, barPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// check foo and bar is ok
results := []string{}
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
@@ -161,7 +159,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
// close bar server, check foo is ok
barServer.Close()
time.Sleep(2 * time.Second)
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
}
@@ -169,7 +167,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
f.RunServer("", barServer)
time.Sleep(2 * time.Second)
results = []string{}
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
@@ -215,7 +213,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
health_check_url = /healthz
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// send first HTTP request
var contents []string
diff --git a/test/e2e/legacy/features/heartbeat.go b/test/e2e/legacy/features/heartbeat.go
index 09bf0e6a..3c1758f2 100644
--- a/test/e2e/legacy/features/heartbeat.go
+++ b/test/e2e/legacy/features/heartbeat.go
@@ -38,7 +38,7 @@ var _ = ginkgo.Describe("[Feature: Heartbeat]", func() {
`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure()
diff --git a/test/e2e/legacy/features/monitor.go b/test/e2e/legacy/features/monitor.go
index 75aa183b..0b19a85e 100644
--- a/test/e2e/legacy/features/monitor.go
+++ b/test/e2e/legacy/features/monitor.go
@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Monitor]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
time.Sleep(500 * time.Millisecond)
diff --git a/test/e2e/legacy/features/real_ip.go b/test/e2e/legacy/features/real_ip.go
index a79afb45..dae74e56 100644
--- a/test/e2e/legacy/features/real_ip.go
+++ b/test/e2e/legacy/features/real_ip.go
@@ -44,7 +44,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
custom_domains = normal.example.com
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -90,7 +90,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
proxy_protocol_version = v2
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -136,7 +136,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
proxy_protocol_version = v2
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("normal.example.com")
diff --git a/test/e2e/legacy/plugin/client.go b/test/e2e/legacy/plugin/client.go
index b69c6aed..ca294280 100644
--- a/test/e2e/legacy/plugin/client.go
+++ b/test/e2e/legacy/plugin/client.go
@@ -4,6 +4,7 @@ import (
"crypto/tls"
"fmt"
"strconv"
+ "strings"
"github.com/onsi/ginkgo/v2"
@@ -22,7 +23,8 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
ginkgo.Describe("UnixDomainSocket", func() {
ginkgo.It("Expose a unix domain socket echo server", func() {
serverConf := consts.LegacyDefaultServerConfig
- clientConf := consts.LegacyDefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.LegacyDefaultClientConfig)
getProxyConf := func(proxyName string, portName string, extra string) string {
return fmt.Sprintf(`
@@ -65,10 +67,10 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()
@@ -90,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_http_passwd = 123
`, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {
@@ -122,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_passwd = 123
`, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {
@@ -166,7 +168,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_http_passwd = 123
`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// from tcp proxy
framework.NewRequestExpect(f).Request(
@@ -200,7 +202,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_local_addr = 127.0.0.1:%d
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -244,7 +246,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_key_path = %s
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -288,7 +290,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
plugin_key_path = %s
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
diff --git a/test/e2e/legacy/plugin/server.go b/test/e2e/legacy/plugin/server.go
index 9120b7d7..b00b1bac 100644
--- a/test/e2e/legacy/plugin/server.go
+++ b/test/e2e/legacy/plugin/server.go
@@ -71,7 +71,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort2)
- f.RunProcesses([]string{serverConf}, []string{clientConf, invalidTokenClientConf})
+ f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()
@@ -119,7 +119,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -153,7 +153,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = 0
`, framework.TCPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -195,7 +195,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- _, clients := f.RunProcesses([]string{serverConf}, []string{clientConf})
+ _, clients := f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -250,7 +250,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -297,7 +297,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -342,7 +342,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -389,7 +389,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remote_port = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
diff --git a/test/e2e/mock/server/oidcserver/oidcserver.go b/test/e2e/mock/server/oidcserver/oidcserver.go
new file mode 100644
index 00000000..d7aa1329
--- /dev/null
+++ b/test/e2e/mock/server/oidcserver/oidcserver.go
@@ -0,0 +1,258 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package oidcserver provides a minimal mock OIDC server for e2e testing.
+// It implements three endpoints:
+// - /.well-known/openid-configuration (discovery)
+// - /jwks (JSON Web Key Set)
+// - /token (client_credentials grant)
+package oidcserver
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "net"
+ "net/http"
+ "strconv"
+ "sync/atomic"
+ "time"
+)
+
+type Server struct {
+ bindAddr string
+ bindPort int
+ l net.Listener
+ hs *http.Server
+
+ privateKey *rsa.PrivateKey
+ kid string
+
+ clientID string
+ clientSecret string
+ audience string
+ subject string
+ expiresIn int // seconds; 0 means omit expires_in from token response
+
+ tokenRequestCount atomic.Int64
+}
+
+type Option func(*Server)
+
+func WithBindPort(port int) Option {
+ return func(s *Server) { s.bindPort = port }
+}
+
+func WithClientCredentials(id, secret string) Option {
+ return func(s *Server) {
+ s.clientID = id
+ s.clientSecret = secret
+ }
+}
+
+func WithAudience(aud string) Option {
+ return func(s *Server) { s.audience = aud }
+}
+
+func WithSubject(sub string) Option {
+ return func(s *Server) { s.subject = sub }
+}
+
+func WithExpiresIn(seconds int) Option {
+ return func(s *Server) { s.expiresIn = seconds }
+}
+
+func New(options ...Option) *Server {
+ s := &Server{
+ bindAddr: "127.0.0.1",
+ kid: "test-key-1",
+ clientID: "test-client",
+ clientSecret: "test-secret",
+ audience: "frps",
+ subject: "test-service",
+ expiresIn: 3600,
+ }
+ for _, opt := range options {
+ opt(s)
+ }
+ return s
+}
+
+func (s *Server) Run() error {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return fmt.Errorf("generate RSA key: %w", err)
+ }
+ s.privateKey = key
+
+ s.l, err = net.Listen("tcp", net.JoinHostPort(s.bindAddr, strconv.Itoa(s.bindPort)))
+ if err != nil {
+ return err
+ }
+ s.bindPort = s.l.Addr().(*net.TCPAddr).Port
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery)
+ mux.HandleFunc("/jwks", s.handleJWKS)
+ mux.HandleFunc("/token", s.handleToken)
+
+ s.hs = &http.Server{
+ Handler: mux,
+ ReadHeaderTimeout: time.Minute,
+ }
+ go func() { _ = s.hs.Serve(s.l) }()
+ return nil
+}
+
+func (s *Server) Close() error {
+ if s.hs != nil {
+ return s.hs.Close()
+ }
+ return nil
+}
+
+func (s *Server) BindAddr() string { return s.bindAddr }
+func (s *Server) BindPort() int { return s.bindPort }
+
+func (s *Server) Issuer() string {
+ return fmt.Sprintf("http://%s:%d", s.bindAddr, s.bindPort)
+}
+
+func (s *Server) TokenEndpoint() string {
+ return s.Issuer() + "/token"
+}
+
+// TokenRequestCount returns the number of successful token requests served.
+func (s *Server) TokenRequestCount() int64 {
+ return s.tokenRequestCount.Load()
+}
+
+func (s *Server) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
+ issuer := s.Issuer()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "issuer": issuer,
+ "token_endpoint": issuer + "/token",
+ "jwks_uri": issuer + "/jwks",
+ "response_types_supported": []string{"code"},
+ "subject_types_supported": []string{"public"},
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ })
+}
+
+func (s *Server) handleJWKS(w http.ResponseWriter, _ *http.Request) {
+ pub := &s.privateKey.PublicKey
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "keys": []map[string]any{
+ {
+ "kty": "RSA",
+ "alg": "RS256",
+ "use": "sig",
+ "kid": s.kid,
+ "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
+ },
+ },
+ })
+}
+
+func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": "invalid_request",
+ })
+ return
+ }
+
+ if r.FormValue("grant_type") != "client_credentials" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": "unsupported_grant_type",
+ })
+ return
+ }
+
+ // Accept credentials from Basic Auth or form body.
+ clientID, clientSecret, ok := r.BasicAuth()
+ if !ok {
+ clientID = r.FormValue("client_id")
+ clientSecret = r.FormValue("client_secret")
+ }
+ if clientID != s.clientID || clientSecret != s.clientSecret {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": "invalid_client",
+ })
+ return
+ }
+
+ token, err := s.signJWT()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ resp := map[string]any{
+ "access_token": token,
+ "token_type": "Bearer",
+ }
+ if s.expiresIn > 0 {
+ resp["expires_in"] = s.expiresIn
+ }
+
+ s.tokenRequestCount.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) signJWT() (string, error) {
+ now := time.Now()
+ header, _ := json.Marshal(map[string]string{
+ "alg": "RS256",
+ "kid": s.kid,
+ "typ": "JWT",
+ })
+ claims, _ := json.Marshal(map[string]any{
+ "iss": s.Issuer(),
+ "sub": s.subject,
+ "aud": s.audience,
+ "iat": now.Unix(),
+ "exp": now.Add(1 * time.Hour).Unix(),
+ })
+
+ headerB64 := base64.RawURLEncoding.EncodeToString(header)
+ claimsB64 := base64.RawURLEncoding.EncodeToString(claims)
+ signingInput := headerB64 + "." + claimsB64
+
+ h := sha256.Sum256([]byte(signingInput))
+ sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, h[:])
+ if err != nil {
+ return "", err
+ }
+ return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
+}
diff --git a/test/e2e/pkg/port/port.go b/test/e2e/pkg/port/port.go
index 49cc9a68..82ac8586 100644
--- a/test/e2e/pkg/port/port.go
+++ b/test/e2e/pkg/port/port.go
@@ -52,7 +52,7 @@ func (pa *Allocator) GetByName(portName string) int {
pa.mu.Lock()
defer pa.mu.Unlock()
- for i := 0; i < 20; i++ {
+ for range 20 {
port := pa.getByRange(builder.rangePortFrom, builder.rangePortTo)
if port == 0 {
return 0
diff --git a/test/e2e/pkg/process/process.go b/test/e2e/pkg/process/process.go
index e6721e1d..8235461b 100644
--- a/test/e2e/pkg/process/process.go
+++ b/test/e2e/pkg/process/process.go
@@ -3,15 +3,44 @@ package process
import (
"bytes"
"context"
+ "errors"
+ "fmt"
"os/exec"
+ "strings"
+ "sync"
+ "time"
)
+// SafeBuffer is a thread-safe wrapper around bytes.Buffer.
+// It is safe to call Write and String concurrently.
+type SafeBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (b *SafeBuffer) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Write(p)
+}
+
+func (b *SafeBuffer) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.String()
+}
+
type Process struct {
cmd *exec.Cmd
cancel context.CancelFunc
- errorOutput *bytes.Buffer
- stdOutput *bytes.Buffer
+ errorOutput *SafeBuffer
+ stdOutput *SafeBuffer
+ done chan struct{}
+ closeOne sync.Once
+ waitErr error
+
+ started bool
beforeStopHandler func()
stopped bool
}
@@ -27,20 +56,45 @@ func NewWithEnvs(path string, params []string, envs []string) *Process {
p := &Process{
cmd: cmd,
cancel: cancel,
+ done: make(chan struct{}),
}
- p.errorOutput = bytes.NewBufferString("")
- p.stdOutput = bytes.NewBufferString("")
+ p.errorOutput = &SafeBuffer{}
+ p.stdOutput = &SafeBuffer{}
cmd.Stderr = p.errorOutput
cmd.Stdout = p.stdOutput
return p
}
func (p *Process) Start() error {
- return p.cmd.Start()
+ if p.started {
+ return errors.New("process already started")
+ }
+ p.started = true
+
+ err := p.cmd.Start()
+ if err != nil {
+ p.waitErr = err
+ p.closeDone()
+ return err
+ }
+ go func() {
+ p.waitErr = p.cmd.Wait()
+ p.closeDone()
+ }()
+ return nil
+}
+
+func (p *Process) closeDone() {
+ p.closeOne.Do(func() { close(p.done) })
+}
+
+// Done returns a channel that is closed when the process exits.
+func (p *Process) Done() <-chan struct{} {
+ return p.done
}
func (p *Process) Stop() error {
- if p.stopped {
+ if p.stopped || !p.started {
return nil
}
defer func() {
@@ -50,7 +104,8 @@ func (p *Process) Stop() error {
p.beforeStopHandler()
}
p.cancel()
- return p.cmd.Wait()
+ <-p.done
+ return p.waitErr
}
func (p *Process) ErrorOutput() string {
@@ -61,6 +116,38 @@ func (p *Process) StdOutput() string {
return p.stdOutput.String()
}
+func (p *Process) Output() string {
+ return p.stdOutput.String() + p.errorOutput.String()
+}
+
+// CountOutput returns how many times pattern appears in the current accumulated output.
+func (p *Process) CountOutput(pattern string) int {
+ return strings.Count(p.Output(), pattern)
+}
+
func (p *Process) SetBeforeStopHandler(fn func()) {
p.beforeStopHandler = fn
}
+
+// WaitForOutput polls the combined process output until the pattern is found
+// count time(s) or the timeout is reached. It also returns early if the process exits.
+func (p *Process) WaitForOutput(pattern string, count int, timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ output := p.Output()
+ if strings.Count(output, pattern) >= count {
+ return nil
+ }
+ select {
+ case <-p.Done():
+ // Process exited, check one last time.
+ output = p.Output()
+ if strings.Count(output, pattern) >= count {
+ return nil
+ }
+ return fmt.Errorf("process exited before %d occurrence(s) of %q found", count, pattern)
+ case <-time.After(25 * time.Millisecond):
+ }
+ }
+ return fmt.Errorf("timeout waiting for %d occurrence(s) of %q", count, pattern)
+}
diff --git a/test/e2e/pkg/ssh/client.go b/test/e2e/pkg/ssh/client.go
index b45e39da..d2525871 100644
--- a/test/e2e/pkg/ssh/client.go
+++ b/test/e2e/pkg/ssh/client.go
@@ -75,11 +75,11 @@ func (c *TunnelClient) serveListener() {
if err != nil {
return
}
- go c.hanldeConn(conn)
+ go c.handleConn(conn)
}
}
-func (c *TunnelClient) hanldeConn(conn net.Conn) {
+func (c *TunnelClient) handleConn(conn net.Conn) {
defer conn.Close()
local, err := net.Dial("tcp", c.localAddr)
if err != nil {
diff --git a/test/e2e/v1/basic/annotations.go b/test/e2e/v1/basic/annotations.go
index 4f4a8a91..78e94a16 100644
--- a/test/e2e/v1/basic/annotations.go
+++ b/test/e2e/v1/basic/annotations.go
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Annotations]", func() {
"frp.e2e.test/bar" = "value2"
`, framework.TCPEchoServerPort, p1Port)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure()
diff --git a/test/e2e/v1/basic/basic.go b/test/e2e/v1/basic/basic.go
index 9d9d34cb..8994ba73 100644
--- a/test/e2e/v1/basic/basic.go
+++ b/test/e2e/v1/basic/basic.go
@@ -26,7 +26,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
proxyType := t
ginkgo.It(fmt.Sprintf("Expose a %s echo server", strings.ToUpper(proxyType)), func() {
serverConf := consts.DefaultServerConfig
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
localPortName := ""
protocol := "tcp"
@@ -79,10 +80,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
framework.NewRequestExpect(f).
@@ -103,7 +104,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
vhostHTTPPort = %d
`, vhostHTTPPort)
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
getProxyConf := func(proxyName string, customDomains string, extra string) string {
return fmt.Sprintf(`
@@ -149,13 +151,13 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
if tests[i].customDomains == "" {
tests[i].customDomains = fmt.Sprintf(`["%s"]`, test.proxyName+".example.com")
}
- clientConf += getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
- for _, domain := range strings.Split(test.customDomains, ",") {
+ for domain := range strings.SplitSeq(test.customDomains, ",") {
domain = strings.TrimSpace(domain)
domain = strings.TrimLeft(domain, "[\"")
domain = strings.TrimRight(domain, "]\"")
@@ -189,7 +191,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
`, vhostHTTPSPort)
localPort := f.AllocPort()
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
getProxyConf := func(proxyName string, customDomains string, extra string) string {
return fmt.Sprintf(`
[[proxies]]
@@ -234,10 +237,10 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
if tests[i].customDomains == "" {
tests[i].customDomains = fmt.Sprintf(`["%s"]`, test.proxyName+".example.com")
}
- clientConf += getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, tests[i].customDomains, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -249,7 +252,7 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
f.RunServer("", localServer)
for _, test := range tests {
- for _, domain := range strings.Split(test.customDomains, ",") {
+ for domain := range strings.SplitSeq(test.customDomains, ",") {
domain = strings.TrimSpace(domain)
domain = strings.TrimLeft(domain, "[\"")
domain = strings.TrimRight(domain, "]\"")
@@ -289,9 +292,12 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
proxyType := t
ginkgo.It(fmt.Sprintf("Expose echo server with %s", strings.ToUpper(proxyType)), func() {
serverConf := consts.DefaultServerConfig
- clientServerConf := consts.DefaultClientConfig + "\nuser = \"user1\""
- clientVisitorConf := consts.DefaultClientConfig + "\nuser = \"user1\""
- clientUser2VisitorConf := consts.DefaultClientConfig + "\nuser = \"user2\""
+ var clientServerConf strings.Builder
+ clientServerConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user1\"")
+ var clientVisitorConf strings.Builder
+ clientVisitorConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user1\"")
+ var clientUser2VisitorConf strings.Builder
+ clientUser2VisitorConf.WriteString(consts.DefaultClientConfig + "\nuser = \"user2\"")
localPortName := ""
protocol := "tcp"
@@ -407,20 +413,20 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientServerConf += getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n"
+ clientServerConf.WriteString(getProxyServerConf(test.proxyName, test.commonExtraConfig+"\n"+test.proxyExtraConfig) + "\n")
}
for _, test := range tests {
config := getProxyVisitorConf(
test.proxyName, test.bindPortName, test.visitorSK, test.commonExtraConfig+"\n"+test.visitorExtraConfig,
) + "\n"
if test.deployUser2Client {
- clientUser2VisitorConf += config
+ clientUser2VisitorConf.WriteString(config)
} else {
- clientVisitorConf += config
+ clientVisitorConf.WriteString(config)
}
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientServerConf, clientVisitorConf, clientUser2VisitorConf})
+ f.RunProcesses(serverConf, []string{clientServerConf.String(), clientVisitorConf.String(), clientUser2VisitorConf.String()})
for _, test := range tests {
timeout := time.Second
@@ -447,7 +453,8 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
ginkgo.Describe("TCPMUX", func() {
ginkgo.It("Type tcpmux", func() {
serverConf := consts.DefaultServerConfig
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
tcpmuxHTTPConnectPortName := port.GenName("TCPMUX")
serverConf += fmt.Sprintf(`
@@ -491,14 +498,14 @@ var _ = ginkgo.Describe("[Feature: Basic]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.extraConfig) + "\n")
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(f.AllocPort()), streamserver.WithRespContent([]byte(test.proxyName)))
f.RunServer(port.GenName(test.proxyName), localServer)
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
// Request without HTTP connect should get error
framework.NewRequestExpect(f).
diff --git a/test/e2e/v1/basic/client.go b/test/e2e/v1/basic/client.go
index fd269d75..ec2011ea 100644
--- a/test/e2e/v1/basic/client.go
+++ b/test/e2e/v1/basic/client.go
@@ -51,7 +51,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
framework.TCPEchoServerPort, p2Port,
framework.TCPEchoServerPort, p3Port)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure()
framework.NewRequestExpect(f).Port(p2Port).Ensure()
@@ -93,7 +93,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
webServer.password = "admin"
`, dashboardPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz")
@@ -120,7 +120,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() {
remotePort = %d
`, adminPort, framework.TCPEchoServerPort, testPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(testPort).Ensure()
diff --git a/test/e2e/v1/basic/client_server.go b/test/e2e/v1/basic/client_server.go
index 85dd1227..3a2fa28d 100644
--- a/test/e2e/v1/basic/client_server.go
+++ b/test/e2e/v1/basic/client_server.go
@@ -78,7 +78,7 @@ func runClientServerTest(f *framework.Framework, configures *generalTestConfigur
clientConfs = append(clientConfs, client2Conf)
}
- f.RunProcesses([]string{serverConf}, clientConfs)
+ f.RunProcesses(serverConf, clientConfs)
if configures.testDelay > 0 {
time.Sleep(configures.testDelay)
diff --git a/test/e2e/v1/basic/config.go b/test/e2e/v1/basic/config.go
index 314e7fc4..e290109e 100644
--- a/test/e2e/v1/basic/config.go
+++ b/test/e2e/v1/basic/config.go
@@ -35,7 +35,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
`, "`", "`", framework.TCPEchoServerPort, portName)
f.SetEnvs([]string{"FRP_TOKEN=123"})
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
@@ -69,7 +69,7 @@ var _ = ginkgo.Describe("[Feature: Config]", func() {
escapeTemplate("{{- end }}"),
)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
client := f.APIClientForFrpc(adminPort)
checkProxyFn := func(name string, localPort, remotePort int) {
@@ -149,7 +149,7 @@ proxies:
remotePort: %d
`, port.GenName("Server"), framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -161,7 +161,7 @@ proxies:
"proxies": [{"name": "tcp", "type": "tcp", "localPort": {{ .%s }}, "remotePort": %d}]}`,
port.GenName("Server"), framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
})
diff --git a/test/e2e/v1/basic/http.go b/test/e2e/v1/basic/http.go
index c37e84e7..b594f1ea 100644
--- a/test/e2e/v1/basic/http.go
+++ b/test/e2e/v1/basic/http.go
@@ -59,7 +59,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
locations = ["/bar"]
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tests := []struct {
path string
@@ -117,7 +117,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["normal.example.com"]
`, fooPort, barPort, otherPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// user1
framework.NewRequestExpect(f).Explain("user1").Port(vhostHTTPPort).
@@ -159,7 +159,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
httpPassword = "test"
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -196,7 +196,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["*.example.com"]
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not match host
framework.NewRequestExpect(f).Port(vhostHTTPPort).
@@ -248,7 +248,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
subdomain = "bar"
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// foo
framework.NewRequestExpect(f).Explain("foo subdomain").Port(vhostHTTPPort).
@@ -290,7 +290,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
requestHeaders.set.x-from-where = "frp"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -323,7 +323,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
responseHeaders.set.x-from-where = "frp"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -357,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
hostHeaderRewrite = "rewrite.example.com"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -406,7 +406,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["127.0.0.1"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
u := url.URL{Scheme: "ws", Host: "127.0.0.1:" + strconv.Itoa(vhostHTTPPort)}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
@@ -447,7 +447,7 @@ var _ = ginkgo.Describe("[Feature: HTTP]", func() {
customDomains = ["normal.example.com"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
diff --git a/test/e2e/v1/basic/oidc.go b/test/e2e/v1/basic/oidc.go
new file mode 100644
index 00000000..19539c76
--- /dev/null
+++ b/test/e2e/v1/basic/oidc.go
@@ -0,0 +1,192 @@
+// Copyright 2026 The frp Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package basic
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/onsi/ginkgo/v2"
+
+ "github.com/fatedier/frp/test/e2e/framework"
+ "github.com/fatedier/frp/test/e2e/framework/consts"
+ "github.com/fatedier/frp/test/e2e/mock/server/oidcserver"
+ "github.com/fatedier/frp/test/e2e/pkg/port"
+)
+
+var _ = ginkgo.Describe("[Feature: OIDC]", func() {
+ f := framework.NewDefaultFramework()
+
+ ginkgo.It("should work with OIDC authentication", func() {
+ oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
+ f.RunServer("", oidcSrv)
+
+ portName := port.GenName("TCP")
+
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.issuer = "%s"
+auth.oidc.audience = "frps"
+`, oidcSrv.Issuer())
+
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.clientID = "test-client"
+auth.oidc.clientSecret = "test-secret"
+auth.oidc.tokenEndpointURL = "%s"
+
+[[proxies]]
+name = "tcp"
+type = "tcp"
+localPort = {{ .%s }}
+remotePort = {{ .%s }}
+`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+ })
+
+ ginkgo.It("should authenticate heartbeats with OIDC", func() {
+ oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
+ f.RunServer("", oidcSrv)
+
+ serverPort := f.AllocPort()
+ remotePort := f.AllocPort()
+
+ serverConf := fmt.Sprintf(`
+bindAddr = "0.0.0.0"
+bindPort = %d
+log.level = "trace"
+auth.method = "oidc"
+auth.additionalScopes = ["HeartBeats"]
+auth.oidc.issuer = "%s"
+auth.oidc.audience = "frps"
+`, serverPort, oidcSrv.Issuer())
+
+ clientConf := fmt.Sprintf(`
+serverAddr = "127.0.0.1"
+serverPort = %d
+loginFailExit = false
+log.level = "trace"
+auth.method = "oidc"
+auth.additionalScopes = ["HeartBeats"]
+auth.oidc.clientID = "test-client"
+auth.oidc.clientSecret = "test-secret"
+auth.oidc.tokenEndpointURL = "%s"
+transport.heartbeatInterval = 1
+
+[[proxies]]
+name = "tcp"
+type = "tcp"
+localPort = %d
+remotePort = %d
+`, serverPort, oidcSrv.TokenEndpoint(), f.PortByName(framework.TCPEchoServerPort), remotePort)
+
+ serverConfigPath := f.GenerateConfigFile(serverConf)
+ clientConfigPath := f.GenerateConfigFile(clientConf)
+
+ _, _, err := f.RunFrps("-c", serverConfigPath)
+ framework.ExpectNoError(err)
+ clientProcess, _, err := f.RunFrpc("-c", clientConfigPath)
+ framework.ExpectNoError(err)
+
+ // Wait for several authenticated heartbeat cycles instead of a fixed sleep.
+ err = clientProcess.WaitForOutput("send heartbeat to server", 3, 10*time.Second)
+ framework.ExpectNoError(err)
+
+ // Proxy should still work: heartbeat auth has not failed.
+ framework.NewRequestExpect(f).Port(remotePort).Ensure()
+ })
+
+ ginkgo.It("should work when token has no expires_in", func() {
+ oidcSrv := oidcserver.New(
+ oidcserver.WithBindPort(f.AllocPort()),
+ oidcserver.WithExpiresIn(0),
+ )
+ f.RunServer("", oidcSrv)
+
+ portName := port.GenName("TCP")
+
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.issuer = "%s"
+auth.oidc.audience = "frps"
+`, oidcSrv.Issuer())
+
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.additionalScopes = ["HeartBeats"]
+auth.oidc.clientID = "test-client"
+auth.oidc.clientSecret = "test-secret"
+auth.oidc.tokenEndpointURL = "%s"
+transport.heartbeatInterval = 1
+
+[[proxies]]
+name = "tcp"
+type = "tcp"
+localPort = {{ .%s }}
+remotePort = {{ .%s }}
+`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
+
+ _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+
+ countAfterLogin := oidcSrv.TokenRequestCount()
+
+ // Wait for several heartbeat cycles instead of a fixed sleep.
+ // Each heartbeat fetches a fresh token in non-caching mode.
+ err := clientProcesses[0].WaitForOutput("send heartbeat to server", 3, 10*time.Second)
+ framework.ExpectNoError(err)
+
+ framework.NewRequestExpect(f).PortName(portName).Ensure()
+
+ // Each heartbeat should have fetched a new token (non-caching mode).
+ countAfterHeartbeats := oidcSrv.TokenRequestCount()
+ framework.ExpectTrue(
+ countAfterHeartbeats > countAfterLogin,
+ "expected additional token requests for heartbeats, got %d before and %d after",
+ countAfterLogin, countAfterHeartbeats,
+ )
+ })
+
+ ginkgo.It("should reject invalid OIDC credentials", func() {
+ oidcSrv := oidcserver.New(oidcserver.WithBindPort(f.AllocPort()))
+ f.RunServer("", oidcSrv)
+
+ portName := port.GenName("TCP")
+
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.issuer = "%s"
+auth.oidc.audience = "frps"
+`, oidcSrv.Issuer())
+
+ clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
+auth.method = "oidc"
+auth.oidc.clientID = "test-client"
+auth.oidc.clientSecret = "wrong-secret"
+auth.oidc.tokenEndpointURL = "%s"
+
+[[proxies]]
+name = "tcp"
+type = "tcp"
+localPort = {{ .%s }}
+remotePort = {{ .%s }}
+`, oidcSrv.TokenEndpoint(), framework.TCPEchoServerPort, portName)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+ framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
+ })
+})
diff --git a/test/e2e/v1/basic/server.go b/test/e2e/v1/basic/server.go
index a3fe5992..f2714abb 100644
--- a/test/e2e/v1/basic/server.go
+++ b/test/e2e/v1/basic/server.go
@@ -33,7 +33,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
udpPortName := port.GenName("UDP", port.WithRangePorts(12000, 13000))
clientConf += fmt.Sprintf(`
[[proxies]]
- name = "tcp-allowded-in-range"
+ name = "tcp-allowed-in-range"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
@@ -67,7 +67,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
remotePort = 11003
`, framework.UDPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// TCP
// Allowed in range
@@ -108,7 +108,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
localPort = {{ .%s }}
`, adminPort, framework.TCPEchoServerPort, framework.UDPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
client := f.APIClientForFrpc(adminPort)
@@ -150,7 +150,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
customDomains = ["example.com"]
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("example.com")
@@ -178,7 +178,7 @@ var _ = ginkgo.Describe("[Feature: Server Manager]", func() {
customDomains = ["example.com"]
`, framework.HTTPSimpleServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
r.HTTP().HTTPPath("/healthz")
diff --git a/test/e2e/v1/basic/tcpmux.go b/test/e2e/v1/basic/tcpmux.go
index 7ee58a79..9b839faa 100644
--- a/test/e2e/v1/basic/tcpmux.go
+++ b/test/e2e/v1/basic/tcpmux.go
@@ -79,7 +79,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
customDomains = ["normal.example.com"]
`, fooPort, barPort, otherPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// user1
framework.NewRequestExpect(f).Explain("user1").
@@ -125,7 +125,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
httpPassword = "test"
`, fooPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// not set auth header
framework.NewRequestExpect(f).Explain("no auth").
@@ -209,7 +209,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() {
customDomains = ["normal.example.com"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) {
diff --git a/test/e2e/v1/basic/token_source.go b/test/e2e/v1/basic/token_source.go
index 4ed7051c..d12dd4de 100644
--- a/test/e2e/v1/basic/token_source.go
+++ b/test/e2e/v1/basic/token_source.go
@@ -16,8 +16,11 @@ package basic
import (
"fmt"
+ "net"
"os"
"path/filepath"
+ "strconv"
+ "time"
"github.com/onsi/ginkgo/v2"
@@ -73,7 +76,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }}
`, tokenContent, framework.TCPEchoServerPort, portName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
@@ -109,7 +112,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }}
`, tokenFile, framework.TCPEchoServerPort, portName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
@@ -150,7 +153,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }}
`, clientTokenFile, framework.TCPEchoServerPort, portName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
@@ -190,7 +193,7 @@ localPort = {{ .%s }}
remotePort = {{ .%s }}
`, clientTokenFile, framework.TCPEchoServerPort, portName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// This should fail due to token mismatch - the client should not be able to connect
// We expect the request to fail because the proxy tunnel is not established
@@ -198,32 +201,27 @@ remotePort = {{ .%s }}
})
ginkgo.It("should fail with non-existent token file", func() {
- // This test verifies that server fails to start when tokenSource points to non-existent file
- // We'll verify this by checking that the configuration loading itself fails
-
- // Create a config that references a non-existent file
tmpDir := f.TempDirectory
nonExistentFile := filepath.Join(tmpDir, "non_existent_token")
- serverConf := consts.DefaultServerConfig
-
- // Server config with non-existent tokenSource file
- serverConf += fmt.Sprintf(`
+ serverPort := f.AllocPort()
+ serverConf := fmt.Sprintf(`
+bindAddr = "0.0.0.0"
+bindPort = %d
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
-`, nonExistentFile)
+`, serverPort, nonExistentFile)
- // The test expectation is that this will fail during the RunProcesses call
- // because the server cannot load the configuration due to missing token file
- defer func() {
- if r := recover(); r != nil {
- // Expected: server should fail to start due to missing file
- ginkgo.By(fmt.Sprintf("Server correctly failed to start: %v", r))
- }
- }()
+ serverConfigPath := f.GenerateConfigFile(serverConf)
- // This should cause a panic or error during server startup
- f.RunProcesses([]string{serverConf}, []string{})
+ _, _, _ = f.RunFrps("-c", serverConfigPath)
+
+ // Server should have failed to start, so the port should not be listening.
+ conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(serverPort)), 1*time.Second)
+ if err == nil {
+ conn.Close()
+ }
+ framework.ExpectTrue(err != nil, "server should not be listening on port %d", serverPort)
})
})
diff --git a/test/e2e/v1/basic/xtcp.go b/test/e2e/v1/basic/xtcp.go
index a5aaf67b..57905f0d 100644
--- a/test/e2e/v1/basic/xtcp.go
+++ b/test/e2e/v1/basic/xtcp.go
@@ -42,7 +42,7 @@ var _ = ginkgo.Describe("[Feature: XTCP]", func() {
fallbackTimeoutMs = 200
`, framework.TCPEchoServerPort, bindPortName)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).
RequestModify(func(r *request.Request) {
r.Timeout(time.Second)
diff --git a/test/e2e/v1/features/bandwidth_limit.go b/test/e2e/v1/features/bandwidth_limit.go
index efcf38ed..11503adb 100644
--- a/test/e2e/v1/features/bandwidth_limit.go
+++ b/test/e2e/v1/features/bandwidth_limit.go
@@ -36,7 +36,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
transport.bandwidthLimit = "10KB"
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now()
@@ -92,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
remotePort = %d
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
content := strings.Repeat("a", 50*1024) // 5KB
start := time.Now()
diff --git a/test/e2e/v1/features/chaos.go b/test/e2e/v1/features/chaos.go
index f5f8b388..8c515251 100644
--- a/test/e2e/v1/features/chaos.go
+++ b/test/e2e/v1/features/chaos.go
@@ -41,24 +41,24 @@ var _ = ginkgo.Describe("[Feature: Chaos]", func() {
// 2. stop frps, expect request failed
_ = ps.Stop()
- time.Sleep(200 * time.Millisecond)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
// 3. restart frps, expect request success
+ successCount := pc.CountOutput("[tcp] start proxy success")
_, _, err = f.RunFrps("-c", serverConfigPath)
framework.ExpectNoError(err)
- time.Sleep(2 * time.Second)
+ framework.ExpectNoError(pc.WaitForOutput("[tcp] start proxy success", successCount+1, 5*time.Second))
framework.NewRequestExpect(f).Port(remotePort).Ensure()
// 4. stop frpc, expect request failed
_ = pc.Stop()
- time.Sleep(200 * time.Millisecond)
+ framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
// 5. restart frpc, expect request success
- _, _, err = f.RunFrpc("-c", clientConfigPath)
+ newPc, _, err := f.RunFrpc("-c", clientConfigPath)
framework.ExpectNoError(err)
- time.Sleep(time.Second)
+ framework.ExpectNoError(newPc.WaitForOutput("[tcp] start proxy success", 1, 5*time.Second))
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
})
diff --git a/test/e2e/v1/features/group.go b/test/e2e/v1/features/group.go
index f6bb1856..85b938a9 100644
--- a/test/e2e/v1/features/group.go
+++ b/test/e2e/v1/features/group.go
@@ -50,12 +50,10 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
return true
})
}
- for i := 0; i < 10; i++ {
- wait.Add(1)
- go func() {
- defer wait.Done()
+ for range 10 {
+ wait.Go(func() {
expectFn()
- }()
+ })
}
wait.Wait()
@@ -94,11 +92,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123"
`, fooPort, remotePort, barPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
fooCount := 0
barCount := 0
- for i := 0; i < 10; i++ {
+ for i := range 10 {
framework.NewRequestExpect(f).Explain("times " + strconv.Itoa(i)).Port(remotePort).Ensure(func(resp *request.Response) bool {
switch string(resp.Content) {
case "foo":
@@ -159,11 +157,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123"
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
fooCount := 0
barCount := 0
- for i := 0; i < 10; i++ {
+ for i := range 10 {
framework.NewRequestExpect(f).
Explain("times " + strconv.Itoa(i)).
Port(vhostHTTPSPort).
@@ -188,6 +186,68 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount)
})
+
+ ginkgo.It("TCPMux httpconnect", func() {
+ vhostPort := f.AllocPort()
+ serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
+ tcpmuxHTTPConnectPort = %d
+ `, vhostPort)
+ clientConf := consts.DefaultClientConfig
+
+ fooPort := f.AllocPort()
+ fooServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(fooPort), streamserver.WithRespContent([]byte("foo")))
+ f.RunServer("", fooServer)
+
+ barPort := f.AllocPort()
+ barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar")))
+ f.RunServer("", barServer)
+
+ clientConf += fmt.Sprintf(`
+ [[proxies]]
+ name = "foo"
+ type = "tcpmux"
+ multiplexer = "httpconnect"
+ localPort = %d
+ customDomains = ["tcpmux-group.example.com"]
+ loadBalancer.group = "test"
+ loadBalancer.groupKey = "123"
+
+ [[proxies]]
+ name = "bar"
+ type = "tcpmux"
+ multiplexer = "httpconnect"
+ localPort = %d
+ customDomains = ["tcpmux-group.example.com"]
+ loadBalancer.group = "test"
+ loadBalancer.groupKey = "123"
+ `, fooPort, barPort)
+
+ f.RunProcesses(serverConf, []string{clientConf})
+
+ proxyURL := fmt.Sprintf("http://127.0.0.1:%d", vhostPort)
+ fooCount := 0
+ barCount := 0
+ for i := range 10 {
+ framework.NewRequestExpect(f).
+ Explain("times " + strconv.Itoa(i)).
+ RequestModify(func(r *request.Request) {
+ r.Addr("tcpmux-group.example.com").Proxy(proxyURL)
+ }).
+ Ensure(func(resp *request.Response) bool {
+ switch string(resp.Content) {
+ case "foo":
+ fooCount++
+ case "bar":
+ barCount++
+ default:
+ return false
+ }
+ return true
+ })
+ }
+
+ framework.ExpectTrue(fooCount > 1 && barCount > 1, "fooCount: %d, barCount: %d", fooCount, barCount)
+ })
})
ginkgo.Describe("Health Check", func() {
@@ -226,11 +286,11 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
healthCheck.intervalSeconds = 1
`, fooPort, remotePort, barPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
// check foo and bar is ok
results := []string{}
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
@@ -239,17 +299,19 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok
+ failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close()
- time.Sleep(2 * time.Second)
- for i := 0; i < 10; i++ {
+ framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
}
// resume bar server, check foo and bar is ok
+ successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer)
- time.Sleep(2 * time.Second)
+ framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
results = []string{}
- for i := 0; i < 10; i++ {
+ for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
@@ -297,7 +359,7 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
healthCheck.path = "/healthz"
`, fooPort, barPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ _, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
// send first HTTP request
var contents []string
@@ -327,15 +389,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok
+ failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close()
- time.Sleep(2 * time.Second)
+ framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo"})
framework.ExpectNotContainElements(results, []string{"bar"})
// resume bar server, check foo and bar is ok
+ successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer)
- time.Sleep(2 * time.Second)
+ framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo", "bar"})
})
diff --git a/test/e2e/v1/features/heartbeat.go b/test/e2e/v1/features/heartbeat.go
index 4f5409fe..08a1b5d9 100644
--- a/test/e2e/v1/features/heartbeat.go
+++ b/test/e2e/v1/features/heartbeat.go
@@ -37,7 +37,7 @@ var _ = ginkgo.Describe("[Feature: Heartbeat]", func() {
`, serverPort, f.PortByName(framework.TCPEchoServerPort), remotePort)
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Protocol("tcp").Port(remotePort).Ensure()
diff --git a/test/e2e/v1/features/monitor.go b/test/e2e/v1/features/monitor.go
index fe92203a..f70dce16 100644
--- a/test/e2e/v1/features/monitor.go
+++ b/test/e2e/v1/features/monitor.go
@@ -34,7 +34,7 @@ var _ = ginkgo.Describe("[Feature: Monitor]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
time.Sleep(500 * time.Millisecond)
diff --git a/test/e2e/v1/features/real_ip.go b/test/e2e/v1/features/real_ip.go
index a52cf0a2..6bebfc2b 100644
--- a/test/e2e/v1/features/real_ip.go
+++ b/test/e2e/v1/features/real_ip.go
@@ -48,7 +48,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
customDomains = ["normal.example.com"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -82,7 +82,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
customDomains = ["normal.example.com"]
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).
RequestModify(func(r *request.Request) {
@@ -112,7 +112,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
localAddr = "127.0.0.1:%d"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -154,7 +154,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
localAddr = "127.0.0.1:%d"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -212,7 +212,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2"
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -262,7 +262,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2"
`, localPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Protocol("udp").Port(remotePort).Ensure(func(resp *request.Response) bool {
log.Tracef("udp proxy protocol get SourceAddr: %s", string(resp.Content))
@@ -309,7 +309,7 @@ var _ = ginkgo.Describe("[Feature: Real IP]", func() {
transport.proxyProtocolVersion = "v2"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(vhostHTTPPort).RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("normal.example.com")
diff --git a/test/e2e/v1/features/ssh_tunnel.go b/test/e2e/v1/features/ssh_tunnel.go
index 0b8725f6..c120eab0 100644
--- a/test/e2e/v1/features/ssh_tunnel.go
+++ b/test/e2e/v1/features/ssh_tunnel.go
@@ -3,6 +3,8 @@ package features
import (
"crypto/tls"
"fmt"
+ "net"
+ "strconv"
"time"
"github.com/onsi/ginkgo/v2"
@@ -25,7 +27,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d
`, sshPort)
- f.RunProcesses([]string{serverConf}, nil)
+ f.RunProcesses(serverConf, nil)
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.TCPEchoServerPort)
remotePort := f.AllocPort()
@@ -49,7 +52,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d
`, vhostPort, sshPort)
- f.RunProcesses([]string{serverConf}, nil)
+ f.RunProcesses(serverConf, nil)
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.HTTPSimpleServerPort)
tc := ssh.NewTunnelClient(
@@ -76,7 +80,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d
`, vhostPort, sshPort)
- f.RunProcesses([]string{serverConf}, nil)
+ f.RunProcesses(serverConf, nil)
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.AllocPort()
testDomain := "test.example.com"
@@ -118,7 +123,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
sshTunnelGateway.bindPort = %d
`, tcpmuxPort, sshPort)
- f.RunProcesses([]string{serverConf}, nil)
+ f.RunProcesses(serverConf, nil)
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.AllocPort()
testDomain := "test.example.com"
@@ -173,7 +179,8 @@ var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() {
bindPort = %d
`, bindPort)
- f.RunProcesses([]string{serverConf}, []string{visitorConf})
+ f.RunProcesses(serverConf, []string{visitorConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(sshPort)), 5*time.Second))
localPort := f.PortByName(framework.TCPEchoServerPort)
tc := ssh.NewTunnelClient(
diff --git a/test/e2e/v1/features/store.go b/test/e2e/v1/features/store.go
new file mode 100644
index 00000000..76a9b05a
--- /dev/null
+++ b/test/e2e/v1/features/store.go
@@ -0,0 +1,322 @@
+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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ proxyConfig := map[string]any{
+ "name": "test-tcp",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ proxyConfig := map[string]any{
+ "name": "test-tcp",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort1), 5*time.Second))
+ framework.NewRequestExpect(f).Port(remotePort1).Ensure()
+
+ proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2
+ 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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort2), 5*time.Second))
+ framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort1), 100*time.Millisecond, 5*time.Second))
+ framework.NewRequestExpect(f).Port(remotePort2).Ensure()
+ framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()
+ })
+
+ 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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ proxyConfig := map[string]any{
+ "name": "test-tcp",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", remotePort), 5*time.Second))
+ framework.NewRequestExpect(f).Port(remotePort).Ensure()
+
+ framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
+ 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
+ })
+
+ framework.ExpectNoError(framework.WaitForTCPUnreachable(fmt.Sprintf("127.0.0.1:%d", remotePort), 100*time.Millisecond, 5*time.Second))
+ framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
+ })
+
+ 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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ proxyConfig := map[string]any{
+ "name": "test-tcp",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "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
+ })
+
+ 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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
+ r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
+ }).Ensure(func(resp *request.Response) bool {
+ return resp.Code == 404
+ })
+ })
+
+ ginkgo.It("rejects mismatched type block", func() {
+ adminPort := 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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ invalidBody, _ := json.Marshal(map[string]any{
+ "name": "bad-proxy",
+ "type": "tcp",
+ "udp": map[string]any{
+ "localPort": 1234,
+ },
+ })
+
+ 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(invalidBody)
+ }).Ensure(func(resp *request.Response) bool {
+ return resp.Code == 400
+ })
+ })
+
+ ginkgo.It("rejects path/body name mismatch on update", 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(serverConf, []string{clientConf})
+ framework.ExpectNoError(framework.WaitForTCPReady(fmt.Sprintf("127.0.0.1:%d", adminPort), 5*time.Second))
+
+ createBody, _ := json.Marshal(map[string]any{
+ "name": "proxy-a",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localIP": "127.0.0.1",
+ "localPort": f.PortByName(framework.TCPEchoServerPort),
+ "remotePort": remotePort,
+ },
+ })
+
+ 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(createBody)
+ }).Ensure(func(resp *request.Response) bool {
+ return resp.Code == 200
+ })
+
+ updateBody, _ := json.Marshal(map[string]any{
+ "name": "proxy-b",
+ "type": "tcp",
+ "tcp": map[string]any{
+ "localIP": "127.0.0.1",
+ "localPort": f.PortByName(framework.TCPEchoServerPort),
+ "remotePort": remotePort,
+ },
+ })
+
+ framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
+ r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/proxy-a").HTTPParams("PUT", "", "/api/store/proxies/proxy-a", map[string]string{
+ "Content-Type": "application/json",
+ }).Body(updateBody)
+ }).Ensure(func(resp *request.Response) bool {
+ return resp.Code == 400
+ })
+ })
+ })
+})
diff --git a/test/e2e/v1/plugin/client.go b/test/e2e/v1/plugin/client.go
index 73e2d863..1ea25a6e 100644
--- a/test/e2e/v1/plugin/client.go
+++ b/test/e2e/v1/plugin/client.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"strconv"
+ "strings"
"github.com/onsi/ginkgo/v2"
@@ -23,7 +24,8 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
ginkgo.Describe("UnixDomainSocket", func() {
ginkgo.It("Expose a unix domain socket echo server", func() {
serverConf := consts.DefaultServerConfig
- clientConf := consts.DefaultClientConfig
+ var clientConf strings.Builder
+ clientConf.WriteString(consts.DefaultClientConfig)
getProxyConf := func(proxyName string, portName string, extra string) string {
return fmt.Sprintf(`
@@ -69,10 +71,10 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
// build all client config
for _, test := range tests {
- clientConf += getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n"
+ clientConf.WriteString(getProxyConf(test.proxyName, test.portName, test.extraConfig) + "\n")
}
// run frps and frpc
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf.String()})
for _, test := range tests {
framework.NewRequestExpect(f).Port(f.PortByName(test.portName)).Ensure()
@@ -96,7 +98,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
httpPassword = "123"
`, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.HTTPSimpleServerPort).RequestModify(func(r *request.Request) {
@@ -130,7 +132,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
password = "123"
`, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// http proxy, no auth info
framework.NewRequestExpect(f).PortName(framework.TCPEchoServerPort).RequestModify(func(r *request.Request) {
@@ -180,7 +182,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
httpPassword = "123"
`, remotePort, f.TempDirectory, f.TempDirectory, f.TempDirectory)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
// from tcp proxy
framework.NewRequestExpect(f).Request(
@@ -216,7 +218,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
localAddr = "127.0.0.1:%d"
`, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -262,7 +264,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s"
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -308,7 +310,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s"
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
tlsConfig, err := transport.NewServerTLSConfig("", "", "")
framework.ExpectNoError(err)
@@ -348,7 +350,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
hostHeaderRewrite = "rewrite.test.com"
`, remotePort, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -383,7 +385,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
requestHeaders.set.x-from-where = "frp"
`, remotePort, localPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
@@ -429,7 +431,7 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
keyPath = "%s"
`, localPort, crtPath, keyPath)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
localServer := httpserver.New(
httpserver.WithBindPort(localPort),
diff --git a/test/e2e/v1/plugin/server.go b/test/e2e/v1/plugin/server.go
index 6637650d..fd684ef5 100644
--- a/test/e2e/v1/plugin/server.go
+++ b/test/e2e/v1/plugin/server.go
@@ -74,7 +74,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort2)
- f.RunProcesses([]string{serverConf}, []string{clientConf, invalidTokenClientConf})
+ f.RunProcesses(serverConf, []string{clientConf, invalidTokenClientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
framework.NewRequestExpect(f).Port(remotePort2).ExpectError(true).Ensure()
@@ -124,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -160,7 +160,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = 0
`, framework.TCPEchoServerPort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
})
@@ -204,7 +204,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- _, clients := f.RunProcesses([]string{serverConf}, []string{clientConf})
+ _, clients := f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -261,7 +261,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -310,7 +310,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -357,7 +357,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
@@ -406,7 +406,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() {
remotePort = %d
`, framework.TCPEchoServerPort, remotePort)
- f.RunProcesses([]string{serverConf}, []string{clientConf})
+ f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Port(remotePort).Ensure()
diff --git a/web/frpc/.eslintrc.cjs b/web/frpc/.eslintrc.cjs
deleted file mode 100644
index d1ed4702..00000000
--- a/web/frpc/.eslintrc.cjs
+++ /dev/null
@@ -1,30 +0,0 @@
-/* eslint-env node */
-require('@rushstack/eslint-patch/modern-module-resolution')
-
-module.exports = {
- root: true,
- extends: [
- 'plugin:vue/vue3-essential',
- 'eslint:recommended',
- '@vue/eslint-config-typescript',
- '@vue/eslint-config-prettier',
- ],
- parserOptions: {
- ecmaVersion: 'latest',
- },
- rules: {
- '@typescript-eslint/no-unused-vars': [
- 'warn',
- {
- argsIgnorePattern: '^_',
- varsIgnorePattern: '^_',
- },
- ],
- 'vue/multi-word-component-names': [
- 'error',
- {
- ignores: ['Overview'],
- },
- ],
- },
-}
diff --git a/web/frpc/Makefile b/web/frpc/Makefile
index 57a88918..adf015e5 100644
--- a/web/frpc/Makefile
+++ b/web/frpc/Makefile
@@ -1,9 +1,9 @@
.PHONY: dist install build preview lint
install:
- @npm install
+ @cd .. && npm install
-build:
+build: install
@npm run build
dev:
diff --git a/web/frpc/components.d.ts b/web/frpc/components.d.ts
index f9d4522a..c58d0bca 100644
--- a/web/frpc/components.d.ts
+++ b/web/frpc/components.d.ts
@@ -7,20 +7,39 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
- ElButton: typeof import('element-plus/es')['ElButton']
- ElCard: typeof import('element-plus/es')['ElCard']
- ElCol: typeof import('element-plus/es')['ElCol']
- ElEmpty: typeof import('element-plus/es')['ElEmpty']
+ ConfigField: typeof import('./src/components/ConfigField.vue')['default']
+ ConfigSection: typeof import('./src/components/ConfigSection.vue')['default']
+ ElDialog: typeof import('element-plus/es')['ElDialog']
+ ElForm: typeof import('element-plus/es')['ElForm']
+ ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
- ElRow: typeof import('element-plus/es')['ElRow']
+ ElPopover: typeof import('element-plus/es')['ElPopover']
+ ElRadio: typeof import('element-plus/es')['ElRadio']
+ ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
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']
+ ProxyAuthSection: typeof import('./src/components/proxy-form/ProxyAuthSection.vue')['default']
+ ProxyBackendSection: typeof import('./src/components/proxy-form/ProxyBackendSection.vue')['default']
+ ProxyBaseSection: typeof import('./src/components/proxy-form/ProxyBaseSection.vue')['default']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
+ ProxyFormLayout: typeof import('./src/components/proxy-form/ProxyFormLayout.vue')['default']
+ ProxyHealthSection: typeof import('./src/components/proxy-form/ProxyHealthSection.vue')['default']
+ ProxyHttpSection: typeof import('./src/components/proxy-form/ProxyHttpSection.vue')['default']
+ ProxyLoadBalanceSection: typeof import('./src/components/proxy-form/ProxyLoadBalanceSection.vue')['default']
+ ProxyMetadataSection: typeof import('./src/components/proxy-form/ProxyMetadataSection.vue')['default']
+ ProxyNatSection: typeof import('./src/components/proxy-form/ProxyNatSection.vue')['default']
+ ProxyRemoteSection: typeof import('./src/components/proxy-form/ProxyRemoteSection.vue')['default']
+ ProxyTransportSection: typeof import('./src/components/proxy-form/ProxyTransportSection.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
- StatCard: typeof import('./src/components/StatCard.vue')['default']
+ StatusPills: typeof import('./src/components/StatusPills.vue')['default']
+ StringListEditor: typeof import('./src/components/StringListEditor.vue')['default']
+ VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default']
+ VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default']
+ VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default']
+ VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default']
+ VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
diff --git a/web/frpc/embed.go b/web/frpc/embed.go
index ce06ff90..77b4863e 100644
--- a/web/frpc/embed.go
+++ b/web/frpc/embed.go
@@ -1,3 +1,5 @@
+//go:build !noweb
+
package frpc
import (
diff --git a/web/frpc/embed_stub.go b/web/frpc/embed_stub.go
new file mode 100644
index 00000000..591a770a
--- /dev/null
+++ b/web/frpc/embed_stub.go
@@ -0,0 +1,3 @@
+//go:build noweb
+
+package frpc
diff --git a/web/frpc/eslint.config.js b/web/frpc/eslint.config.js
new file mode 100644
index 00000000..a4cce6d4
--- /dev/null
+++ b/web/frpc/eslint.config.js
@@ -0,0 +1,36 @@
+import pluginVue from 'eslint-plugin-vue'
+import vueTsEslintConfig from '@vue/eslint-config-typescript'
+import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
+
+export default [
+ {
+ name: 'app/files-to-lint',
+ files: ['**/*.{ts,mts,tsx,vue}'],
+ },
+ {
+ name: 'app/files-to-ignore',
+ ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
+ },
+ ...pluginVue.configs['flat/essential'],
+ ...vueTsEslintConfig(),
+ {
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ caughtErrorsIgnorePattern: '^_',
+ },
+ ],
+ 'vue/multi-word-component-names': [
+ 'error',
+ {
+ ignores: ['Overview'],
+ },
+ ],
+ },
+ },
+ skipFormatting,
+]
diff --git a/web/frpc/index.html b/web/frpc/index.html
index 0c7caa7b..a893c9c1 100644
--- a/web/frpc/index.html
+++ b/web/frpc/index.html
@@ -3,6 +3,7 @@
+
frp client
diff --git a/web/frpc/package-lock.json b/web/frpc/package-lock.json
deleted file mode 100644
index 6b0912eb..00000000
--- a/web/frpc/package-lock.json
+++ /dev/null
@@ -1,5857 +0,0 @@
-{
- "name": "frpc-dashboard",
- "version": "0.0.1",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "frpc-dashboard",
- "version": "0.0.1",
- "dependencies": {
- "element-plus": "^2.13.0",
- "vue": "^3.5.26",
- "vue-router": "^4.6.4"
- },
- "devDependencies": {
- "@rushstack/eslint-patch": "^1.15.0",
- "@types/node": "24",
- "@vitejs/plugin-vue": "^6.0.3",
- "@vue/eslint-config-prettier": "^9.0.0",
- "@vue/eslint-config-typescript": "^12.0.0",
- "@vue/tsconfig": "^0.8.1",
- "@vueuse/core": "^14.1.0",
- "eslint": "^8.56.0",
- "eslint-plugin-vue": "^9.33.0",
- "npm-run-all": "^4.1.5",
- "prettier": "^3.7.4",
- "sass": "^1.97.2",
- "terser": "^5.44.1",
- "typescript": "^5.9.3",
- "unplugin-auto-import": "^0.17.5",
- "unplugin-element-plus": "^0.11.2",
- "unplugin-vue-components": "^0.26.0",
- "vite": "^7.3.0",
- "vite-svg-loader": "^5.1.0",
- "vue-tsc": "^3.2.2"
- }
- },
- "node_modules/@aashutoshrathi/word-wrap": {
- "version": "1.2.6",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/@antfu/utils": {
- "version": "0.7.7",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.28.5",
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.28.5"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.28.5",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@ctrl/tinycolor": {
- "version": "3.6.0",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@element-plus/icons-vue": {
- "version": "2.3.2",
- "license": "MIT",
- "peerDependencies": {
- "vue": "^3.2.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
- "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
- "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
- "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
- "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
- "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.27.2",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
- "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
- "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
- "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
- "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
- "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
- "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
- "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
- "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
- "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
- "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
- "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
- "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
- "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
- "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
- "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
- "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
- "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
- "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
- "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
- "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.3.0"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.10.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ajv": "^6.12.4",
- "debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
- "strip-json-comments": "^3.1.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint/js": {
- "version": "8.56.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- }
- },
- "node_modules/@floating-ui/core": {
- "version": "1.2.1",
- "license": "MIT"
- },
- "node_modules/@floating-ui/dom": {
- "version": "1.2.1",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/core": "^1.2.1"
- }
- },
- "node_modules/@humanwhocodes/config-array": {
- "version": "0.11.14",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanwhocodes/object-schema": "^2.0.2",
- "debug": "^4.3.1",
- "minimatch": "^3.0.5"
- },
- "engines": {
- "node": ">=10.10.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@humanwhocodes/object-schema": {
- "version": "2.0.2",
- "dev": true,
- "license": "BSD-3-Clause"
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/source-map": {
- "version": "0.3.11",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@parcel/watcher": {
- "version": "2.5.1",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "detect-libc": "^1.0.3",
- "is-glob": "^4.0.3",
- "micromatch": "^4.0.5",
- "node-addon-api": "^7.0.0"
- },
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "@parcel/watcher-android-arm64": "2.5.1",
- "@parcel/watcher-darwin-arm64": "2.5.1",
- "@parcel/watcher-darwin-x64": "2.5.1",
- "@parcel/watcher-freebsd-x64": "2.5.1",
- "@parcel/watcher-linux-arm-glibc": "2.5.1",
- "@parcel/watcher-linux-arm-musl": "2.5.1",
- "@parcel/watcher-linux-arm64-glibc": "2.5.1",
- "@parcel/watcher-linux-arm64-musl": "2.5.1",
- "@parcel/watcher-linux-x64-glibc": "2.5.1",
- "@parcel/watcher-linux-x64-musl": "2.5.1",
- "@parcel/watcher-win32-arm64": "2.5.1",
- "@parcel/watcher-win32-ia32": "2.5.1",
- "@parcel/watcher-win32-x64": "2.5.1"
- }
- },
- "node_modules/@parcel/watcher-android-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
- "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-darwin-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
- "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-darwin-x64": {
- "version": "2.5.1",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-freebsd-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
- "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
- "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
- "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm64-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
- "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-arm64-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
- "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-x64-glibc": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
- "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-linux-x64-musl": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
- "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-arm64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
- "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-ia32": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
- "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@parcel/watcher-win32-x64": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
- "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/@pkgr/core": {
- "version": "0.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- }
- },
- "node_modules/@popperjs/core": {
- "name": "@sxzz/popperjs-es",
- "version": "2.11.7",
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/popperjs"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.53",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/pluginutils": {
- "version": "5.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0",
- "estree-walker": "^2.0.2",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
- },
- "peerDependenciesMeta": {
- "rollup": {
- "optional": true
- }
- }
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
- "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
- "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
- "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.55.1",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
- "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
- "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
- "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
- "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
- "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
- "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
- "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
- "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
- "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
- "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
- "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
- "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
- "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
- "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
- "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
- "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
- "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
- "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
- "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
- "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.55.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
- "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rushstack/eslint-patch": {
- "version": "1.15.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@trysound/sax": {
- "version": "0.2.0",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/@types/estree": {
- "version": "1.0.8",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/json-schema": {
- "version": "7.0.15",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/lodash": {
- "version": "4.17.21",
- "license": "MIT"
- },
- "node_modules/@types/lodash-es": {
- "version": "4.17.12",
- "license": "MIT",
- "dependencies": {
- "@types/lodash": "*"
- }
- },
- "node_modules/@types/node": {
- "version": "24.10.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": "~7.16.0"
- }
- },
- "node_modules/@types/semver": {
- "version": "7.5.6",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/web-bluetooth": {
- "version": "0.0.21",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@typescript-eslint/eslint-plugin": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/regexpp": "^4.5.1",
- "@typescript-eslint/scope-manager": "6.20.0",
- "@typescript-eslint/type-utils": "6.20.0",
- "@typescript-eslint/utils": "6.20.0",
- "@typescript-eslint/visitor-keys": "6.20.0",
- "debug": "^4.3.4",
- "graphemer": "^1.4.0",
- "ignore": "^5.2.4",
- "natural-compare": "^1.4.0",
- "semver": "^7.5.4",
- "ts-api-utils": "^1.0.1"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
- "eslint": "^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/parser": {
- "version": "6.20.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@typescript-eslint/scope-manager": "6.20.0",
- "@typescript-eslint/types": "6.20.0",
- "@typescript-eslint/typescript-estree": "6.20.0",
- "@typescript-eslint/visitor-keys": "6.20.0",
- "debug": "^4.3.4"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/scope-manager": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "6.20.0",
- "@typescript-eslint/visitor-keys": "6.20.0"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/type-utils": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/typescript-estree": "6.20.0",
- "@typescript-eslint/utils": "6.20.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^1.0.1"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^7.0.0 || ^8.0.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/types": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/typescript-estree": {
- "version": "6.20.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@typescript-eslint/types": "6.20.0",
- "@typescript-eslint/visitor-keys": "6.20.0",
- "debug": "^4.3.4",
- "globby": "^11.1.0",
- "is-glob": "^4.0.3",
- "minimatch": "9.0.3",
- "semver": "^7.5.4",
- "ts-api-utils": "^1.0.1"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/@typescript-eslint/utils": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.4.0",
- "@types/json-schema": "^7.0.12",
- "@types/semver": "^7.5.0",
- "@typescript-eslint/scope-manager": "6.20.0",
- "@typescript-eslint/types": "6.20.0",
- "@typescript-eslint/typescript-estree": "6.20.0",
- "semver": "^7.5.4"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/@typescript-eslint/visitor-keys": {
- "version": "6.20.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "6.20.0",
- "eslint-visitor-keys": "^3.4.1"
- },
- "engines": {
- "node": "^16.0.0 || >=18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@ungap/structured-clone": {
- "version": "1.2.0",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/@vitejs/plugin-vue": {
- "version": "6.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rolldown/pluginutils": "1.0.0-beta.53"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
- "vue": "^3.2.25"
- }
- },
- "node_modules/@volar/language-core": {
- "version": "2.4.27",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/source-map": "2.4.27"
- }
- },
- "node_modules/@volar/source-map": {
- "version": "2.4.27",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@volar/typescript": {
- "version": "2.4.27",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/language-core": "2.4.27",
- "path-browserify": "^1.0.1",
- "vscode-uri": "^3.0.8"
- }
- },
- "node_modules/@vue/compiler-core": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.28.5",
- "@vue/shared": "3.5.26",
- "entities": "^7.0.0",
- "estree-walker": "^2.0.2",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/@vue/compiler-core/node_modules/entities": {
- "version": "7.0.0",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "node_modules/@vue/compiler-dom": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-core": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/compiler-sfc": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.28.5",
- "@vue/compiler-core": "3.5.26",
- "@vue/compiler-dom": "3.5.26",
- "@vue/compiler-ssr": "3.5.26",
- "@vue/shared": "3.5.26",
- "estree-walker": "^2.0.2",
- "magic-string": "^0.30.21",
- "postcss": "^8.5.6",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/@vue/compiler-ssr": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-dom": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/devtools-api": {
- "version": "6.6.4",
- "license": "MIT"
- },
- "node_modules/@vue/eslint-config-prettier": {
- "version": "9.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-config-prettier": "^9.0.0",
- "eslint-plugin-prettier": "^5.0.0"
- },
- "peerDependencies": {
- "eslint": ">= 8.0.0",
- "prettier": ">= 3.0.0"
- }
- },
- "node_modules/@vue/eslint-config-typescript": {
- "version": "12.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/eslint-plugin": "^6.7.0",
- "@typescript-eslint/parser": "^6.7.0",
- "vue-eslint-parser": "^9.3.1"
- },
- "engines": {
- "node": "^14.17.0 || >=16.0.0"
- },
- "peerDependencies": {
- "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0",
- "eslint-plugin-vue": "^9.0.0",
- "typescript": "*"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@vue/language-core": {
- "version": "3.2.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/language-core": "2.4.27",
- "@vue/compiler-dom": "^3.5.0",
- "@vue/shared": "^3.5.0",
- "alien-signals": "^3.0.0",
- "muggle-string": "^0.4.1",
- "path-browserify": "^1.0.1",
- "picomatch": "^4.0.2"
- }
- },
- "node_modules/@vue/language-core/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/@vue/reactivity": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/runtime-core": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/reactivity": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/runtime-dom": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/reactivity": "3.5.26",
- "@vue/runtime-core": "3.5.26",
- "@vue/shared": "3.5.26",
- "csstype": "^3.2.3"
- }
- },
- "node_modules/@vue/server-renderer": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-ssr": "3.5.26",
- "@vue/shared": "3.5.26"
- },
- "peerDependencies": {
- "vue": "3.5.26"
- }
- },
- "node_modules/@vue/shared": {
- "version": "3.5.26",
- "license": "MIT"
- },
- "node_modules/@vue/tsconfig": {
- "version": "0.8.1",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "typescript": "5.x",
- "vue": "^3.4.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- },
- "vue": {
- "optional": true
- }
- }
- },
- "node_modules/@vueuse/core": {
- "version": "14.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/web-bluetooth": "^0.0.21",
- "@vueuse/metadata": "14.1.0",
- "@vueuse/shared": "14.1.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "vue": "^3.5.0"
- }
- },
- "node_modules/@vueuse/metadata": {
- "version": "14.1.0",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/@vueuse/shared": {
- "version": "14.1.0",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "vue": "^3.5.0"
- }
- },
- "node_modules/acorn": {
- "version": "8.15.0",
- "dev": true,
- "license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/ajv": {
- "version": "6.12.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/alien-signals": {
- "version": "3.1.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-styles": {
- "version": "3.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^1.9.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/anymatch": {
- "version": "3.1.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/argparse": {
- "version": "2.0.1",
- "dev": true,
- "license": "Python-2.0"
- },
- "node_modules/array-union": {
- "version": "2.1.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/async-validator": {
- "version": "4.2.5",
- "license": "MIT"
- },
- "node_modules/available-typed-arrays": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/binary-extensions": {
- "version": "2.2.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/boolbase": {
- "version": "1.0.0",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/brace-expansion": {
- "version": "1.1.11",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/braces": {
- "version": "3.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/buffer-from": {
- "version": "1.1.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/c12": {
- "version": "3.3.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chokidar": "^5.0.0",
- "confbox": "^0.2.2",
- "defu": "^6.1.4",
- "dotenv": "^17.2.3",
- "exsolve": "^1.0.8",
- "giget": "^2.0.0",
- "jiti": "^2.6.1",
- "ohash": "^2.0.11",
- "pathe": "^2.0.3",
- "perfect-debounce": "^2.0.0",
- "pkg-types": "^2.3.0",
- "rc9": "^2.1.2"
- },
- "peerDependencies": {
- "magicast": "*"
- },
- "peerDependenciesMeta": {
- "magicast": {
- "optional": true
- }
- }
- },
- "node_modules/c12/node_modules/chokidar": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "readdirp": "^5.0.0"
- },
- "engines": {
- "node": ">= 20.19.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/c12/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/c12/node_modules/pkg-types": {
- "version": "2.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
- "pathe": "^2.0.3"
- }
- },
- "node_modules/c12/node_modules/readdirp": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 20.19.0"
- },
- "funding": {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/call-bind": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.1",
- "get-intrinsic": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/callsites": {
- "version": "3.1.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/chalk": {
- "version": "2.4.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^3.2.1",
- "escape-string-regexp": "^1.0.5",
- "supports-color": "^5.3.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/chalk/node_modules/escape-string-regexp": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.0"
- }
- },
- "node_modules/chokidar": {
- "version": "3.5.3",
- "dev": true,
- "funding": [
- {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "engines": {
- "node": ">= 8.10.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/citty": {
- "version": "0.1.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "consola": "^3.2.3"
- }
- },
- "node_modules/color-convert": {
- "version": "1.9.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "1.1.3"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/commander": {
- "version": "2.20.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/concat-map": {
- "version": "0.0.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/confbox": {
- "version": "0.2.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/consola": {
- "version": "3.4.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^14.18.0 || >=16.10.0"
- }
- },
- "node_modules/cross-spawn": {
- "version": "6.0.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "nice-try": "^1.0.4",
- "path-key": "^2.0.1",
- "semver": "^5.5.0",
- "shebang-command": "^1.2.0",
- "which": "^1.2.9"
- },
- "engines": {
- "node": ">=4.8"
- }
- },
- "node_modules/cross-spawn/node_modules/semver": {
- "version": "5.7.1",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver"
- }
- },
- "node_modules/css-select": {
- "version": "5.2.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "boolbase": "^1.0.0",
- "css-what": "^6.1.0",
- "domhandler": "^5.0.2",
- "domutils": "^3.0.1",
- "nth-check": "^2.0.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/fb55"
- }
- },
- "node_modules/css-tree": {
- "version": "2.3.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.0.30",
- "source-map-js": "^1.0.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
- "node_modules/css-what": {
- "version": "6.2.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">= 6"
- },
- "funding": {
- "url": "https://github.com/sponsors/fb55"
- }
- },
- "node_modules/cssesc": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "bin": {
- "cssesc": "bin/cssesc"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/csso": {
- "version": "5.0.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "css-tree": "~2.2.0"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
- "npm": ">=7.0.0"
- }
- },
- "node_modules/csso/node_modules/css-tree": {
- "version": "2.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.0.28",
- "source-map-js": "^1.0.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
- "npm": ">=7.0.0"
- }
- },
- "node_modules/csso/node_modules/mdn-data": {
- "version": "2.0.28",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "license": "MIT"
- },
- "node_modules/dayjs": {
- "version": "1.11.19",
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.3.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "2.1.2"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/deep-is": {
- "version": "0.1.4",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/define-properties": {
- "version": "1.2.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-property-descriptors": "^1.0.0",
- "object-keys": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/defu": {
- "version": "6.1.4",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/destr": {
- "version": "2.0.5",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/detect-libc": {
- "version": "1.0.3",
- "dev": true,
- "license": "Apache-2.0",
- "optional": true,
- "bin": {
- "detect-libc": "bin/detect-libc.js"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/dir-glob": {
- "version": "3.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-type": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/dir-glob/node_modules/path-type": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/doctrine": {
- "version": "3.0.0",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/dom-serializer": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "domelementtype": "^2.3.0",
- "domhandler": "^5.0.2",
- "entities": "^4.2.0"
- },
- "funding": {
- "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
- }
- },
- "node_modules/domelementtype": {
- "version": "2.3.0",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/fb55"
- }
- ],
- "license": "BSD-2-Clause"
- },
- "node_modules/domhandler": {
- "version": "5.0.3",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "domelementtype": "^2.3.0"
- },
- "engines": {
- "node": ">= 4"
- },
- "funding": {
- "url": "https://github.com/fb55/domhandler?sponsor=1"
- }
- },
- "node_modules/domutils": {
- "version": "3.2.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "dom-serializer": "^2.0.0",
- "domelementtype": "^2.3.0",
- "domhandler": "^5.0.3"
- },
- "funding": {
- "url": "https://github.com/fb55/domutils?sponsor=1"
- }
- },
- "node_modules/dotenv": {
- "version": "17.2.3",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://dotenvx.com"
- }
- },
- "node_modules/element-plus": {
- "version": "2.13.0",
- "license": "MIT",
- "dependencies": {
- "@ctrl/tinycolor": "^3.4.1",
- "@element-plus/icons-vue": "^2.3.2",
- "@floating-ui/dom": "^1.0.1",
- "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
- "@types/lodash": "^4.17.20",
- "@types/lodash-es": "^4.17.12",
- "@vueuse/core": "^10.11.0",
- "async-validator": "^4.2.5",
- "dayjs": "^1.11.19",
- "lodash": "^4.17.21",
- "lodash-es": "^4.17.21",
- "lodash-unified": "^1.0.3",
- "memoize-one": "^6.0.0",
- "normalize-wheel-es": "^1.2.0"
- },
- "peerDependencies": {
- "vue": "^3.3.0"
- }
- },
- "node_modules/element-plus/node_modules/@types/web-bluetooth": {
- "version": "0.0.20",
- "license": "MIT"
- },
- "node_modules/element-plus/node_modules/@vueuse/core": {
- "version": "10.11.1",
- "license": "MIT",
- "dependencies": {
- "@types/web-bluetooth": "^0.0.20",
- "@vueuse/metadata": "10.11.1",
- "@vueuse/shared": "10.11.1",
- "vue-demi": ">=0.14.8"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/element-plus/node_modules/@vueuse/core/node_modules/vue-demi": {
- "version": "0.14.10",
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
- "node_modules/element-plus/node_modules/@vueuse/metadata": {
- "version": "10.11.1",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/element-plus/node_modules/@vueuse/shared": {
- "version": "10.11.1",
- "license": "MIT",
- "dependencies": {
- "vue-demi": ">=0.14.8"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/element-plus/node_modules/@vueuse/shared/node_modules/vue-demi": {
- "version": "0.14.10",
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
- "node_modules/entities": {
- "version": "4.5.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "node_modules/error-ex": {
- "version": "1.3.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-arrayish": "^0.2.1"
- }
- },
- "node_modules/errx": {
- "version": "0.1.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/es-abstract": {
- "version": "1.21.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "available-typed-arrays": "^1.0.5",
- "call-bind": "^1.0.2",
- "es-set-tostringtag": "^2.0.1",
- "es-to-primitive": "^1.2.1",
- "function-bind": "^1.1.1",
- "function.prototype.name": "^1.1.5",
- "get-intrinsic": "^1.1.3",
- "get-symbol-description": "^1.0.0",
- "globalthis": "^1.0.3",
- "gopd": "^1.0.1",
- "has": "^1.0.3",
- "has-property-descriptors": "^1.0.0",
- "has-proto": "^1.0.1",
- "has-symbols": "^1.0.3",
- "internal-slot": "^1.0.4",
- "is-array-buffer": "^3.0.1",
- "is-callable": "^1.2.7",
- "is-negative-zero": "^2.0.2",
- "is-regex": "^1.1.4",
- "is-shared-array-buffer": "^1.0.2",
- "is-string": "^1.0.7",
- "is-typed-array": "^1.1.10",
- "is-weakref": "^1.0.2",
- "object-inspect": "^1.12.2",
- "object-keys": "^1.1.1",
- "object.assign": "^4.1.4",
- "regexp.prototype.flags": "^1.4.3",
- "safe-regex-test": "^1.0.0",
- "string.prototype.trimend": "^1.0.6",
- "string.prototype.trimstart": "^1.0.6",
- "typed-array-length": "^1.0.4",
- "unbox-primitive": "^1.0.2",
- "which-typed-array": "^1.1.9"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/es-module-lexer": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-intrinsic": "^1.1.3",
- "has": "^1.0.3",
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-to-primitive": {
- "version": "1.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-callable": "^1.1.4",
- "is-date-object": "^1.0.1",
- "is-symbol": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/esbuild": {
- "version": "0.27.2",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.2",
- "@esbuild/android-arm": "0.27.2",
- "@esbuild/android-arm64": "0.27.2",
- "@esbuild/android-x64": "0.27.2",
- "@esbuild/darwin-arm64": "0.27.2",
- "@esbuild/darwin-x64": "0.27.2",
- "@esbuild/freebsd-arm64": "0.27.2",
- "@esbuild/freebsd-x64": "0.27.2",
- "@esbuild/linux-arm": "0.27.2",
- "@esbuild/linux-arm64": "0.27.2",
- "@esbuild/linux-ia32": "0.27.2",
- "@esbuild/linux-loong64": "0.27.2",
- "@esbuild/linux-mips64el": "0.27.2",
- "@esbuild/linux-ppc64": "0.27.2",
- "@esbuild/linux-riscv64": "0.27.2",
- "@esbuild/linux-s390x": "0.27.2",
- "@esbuild/linux-x64": "0.27.2",
- "@esbuild/netbsd-arm64": "0.27.2",
- "@esbuild/netbsd-x64": "0.27.2",
- "@esbuild/openbsd-arm64": "0.27.2",
- "@esbuild/openbsd-x64": "0.27.2",
- "@esbuild/openharmony-arm64": "0.27.2",
- "@esbuild/sunos-x64": "0.27.2",
- "@esbuild/win32-arm64": "0.27.2",
- "@esbuild/win32-ia32": "0.27.2",
- "@esbuild/win32-x64": "0.27.2"
- }
- },
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/eslint": {
- "version": "8.56.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.56.0",
- "@humanwhocodes/config-array": "^0.11.13",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@nodelib/fs.walk": "^1.2.8",
- "@ungap/structured-clone": "^1.2.0",
- "ajv": "^6.12.4",
- "chalk": "^4.0.0",
- "cross-spawn": "^7.0.2",
- "debug": "^4.3.2",
- "doctrine": "^3.0.0",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.2.2",
- "eslint-visitor-keys": "^3.4.3",
- "espree": "^9.6.1",
- "esquery": "^1.4.2",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^6.0.1",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "globals": "^13.19.0",
- "graphemer": "^1.4.0",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "is-path-inside": "^3.0.3",
- "js-yaml": "^4.1.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
- "lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3",
- "strip-ansi": "^6.0.1",
- "text-table": "^0.2.0"
- },
- "bin": {
- "eslint": "bin/eslint.js"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-config-prettier": {
- "version": "9.1.0",
- "dev": true,
- "license": "MIT",
- "bin": {
- "eslint-config-prettier": "bin/cli.js"
- },
- "peerDependencies": {
- "eslint": ">=7.0.0"
- }
- },
- "node_modules/eslint-plugin-prettier": {
- "version": "5.1.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prettier-linter-helpers": "^1.0.0",
- "synckit": "^0.8.6"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint-plugin-prettier"
- },
- "peerDependencies": {
- "@types/eslint": ">=8.0.0",
- "eslint": ">=8.0.0",
- "eslint-config-prettier": "*",
- "prettier": ">=3.0.0"
- },
- "peerDependenciesMeta": {
- "@types/eslint": {
- "optional": true
- },
- "eslint-config-prettier": {
- "optional": true
- }
- }
- },
- "node_modules/eslint-plugin-vue": {
- "version": "9.33.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.4.0",
- "globals": "^13.24.0",
- "natural-compare": "^1.4.0",
- "nth-check": "^2.1.1",
- "postcss-selector-parser": "^6.0.15",
- "semver": "^7.6.3",
- "vue-eslint-parser": "^9.4.3",
- "xml-name-validator": "^4.0.0"
- },
- "engines": {
- "node": "^14.17.0 || >=16.0.0"
- },
- "peerDependencies": {
- "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
- }
- },
- "node_modules/eslint-scope": {
- "version": "7.2.2",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "3.3.0",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- }
- },
- "node_modules/eslint/node_modules/ansi-styles": {
- "version": "4.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/eslint/node_modules/chalk": {
- "version": "4.1.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/eslint/node_modules/color-convert": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/eslint/node_modules/color-name": {
- "version": "1.1.4",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/eslint/node_modules/cross-spawn": {
- "version": "7.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/eslint/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint/node_modules/glob-parent": {
- "version": "6.0.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/eslint/node_modules/has-flag": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/path-key": {
- "version": "3.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/shebang-command": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/shebang-regex": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/supports-color": {
- "version": "7.2.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eslint/node_modules/which": {
- "version": "2.0.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/espree": {
- "version": "9.6.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.9.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/espree/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/esquery": {
- "version": "1.5.0",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "estraverse": "^5.1.0"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/esrecurse": {
- "version": "4.3.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estraverse": {
- "version": "5.3.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estree-walker": {
- "version": "2.0.2",
- "license": "MIT"
- },
- "node_modules/esutils": {
- "version": "2.0.3",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/exsolve": {
- "version": "1.0.8",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-diff": {
- "version": "1.2.0",
- "dev": true,
- "license": "Apache-2.0"
- },
- "node_modules/fast-glob": {
- "version": "3.3.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.4"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-levenshtein": {
- "version": "2.0.6",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fastq": {
- "version": "1.15.0",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
- "node_modules/file-entry-cache": {
- "version": "6.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flat-cache": "^3.0.4"
- },
- "engines": {
- "node": "^10.12.0 || >=12.0.0"
- }
- },
- "node_modules/fill-range": {
- "version": "7.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/find-up": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "locate-path": "^6.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/flat-cache": {
- "version": "3.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flatted": "^3.1.0",
- "rimraf": "^3.0.2"
- },
- "engines": {
- "node": "^10.12.0 || >=12.0.0"
- }
- },
- "node_modules/flatted": {
- "version": "3.2.7",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/for-each": {
- "version": "0.3.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-callable": "^1.1.3"
- }
- },
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/fsevents": {
- "version": "2.3.2",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/function.prototype.name": {
- "version": "1.1.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.3",
- "es-abstract": "^1.19.0",
- "functions-have-names": "^1.2.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/functions-have-names": {
- "version": "1.2.3",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.2.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.1",
- "has": "^1.0.3",
- "has-symbols": "^1.0.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-symbol-description": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/giget": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "citty": "^0.1.6",
- "consola": "^3.4.0",
- "defu": "^6.1.4",
- "node-fetch-native": "^1.6.6",
- "nypm": "^0.6.0",
- "pathe": "^2.0.3"
- },
- "bin": {
- "giget": "dist/cli.mjs"
- }
- },
- "node_modules/giget/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/glob": {
- "version": "7.2.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
- "engines": {
- "node": "*"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/glob-parent": {
- "version": "5.1.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/globals": {
- "version": "13.24.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "type-fest": "^0.20.2"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/globalthis": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "define-properties": "^1.1.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/globby": {
- "version": "11.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array-union": "^2.1.0",
- "dir-glob": "^3.0.1",
- "fast-glob": "^3.2.9",
- "ignore": "^5.2.0",
- "merge2": "^1.4.1",
- "slash": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/globby/node_modules/fast-glob": {
- "version": "3.2.12",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.4"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/gopd": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-intrinsic": "^1.1.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/graceful-fs": {
- "version": "4.2.10",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/graphemer": {
- "version": "1.4.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/has": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4.0"
- }
- },
- "node_modules/has-bigints": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-flag": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/has-property-descriptors": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-intrinsic": "^1.1.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-proto": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/hasown/node_modules/function-bind": {
- "version": "1.1.2",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hosted-git-info": {
- "version": "2.8.9",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/ignore": {
- "version": "5.2.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/immutable": {
- "version": "5.1.4",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/import-fresh": {
- "version": "3.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "parent-module": "^1.0.0",
- "resolve-from": "^4.0.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
- "node_modules/inflight": {
- "version": "1.0.6",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
- "node_modules/inherits": {
- "version": "2.0.4",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/internal-slot": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "get-intrinsic": "^1.2.0",
- "has": "^1.0.3",
- "side-channel": "^1.0.4"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/is-array-buffer": {
- "version": "3.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.1.3",
- "is-typed-array": "^1.1.10"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-arrayish": {
- "version": "0.2.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/is-bigint": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-bigints": "^1.0.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-binary-path": {
- "version": "2.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "binary-extensions": "^2.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-boolean-object": {
- "version": "1.1.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-callable": {
- "version": "1.2.7",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-core-module": {
- "version": "2.13.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "hasown": "^2.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-date-object": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-negative-zero": {
- "version": "2.0.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-number": {
- "version": "7.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
- "node_modules/is-number-object": {
- "version": "1.0.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-path-inside": {
- "version": "3.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-regex": {
- "version": "1.1.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-shared-array-buffer": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-string": {
- "version": "1.0.7",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-symbol": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-typed-array": {
- "version": "1.1.10",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "available-typed-arrays": "^1.0.5",
- "call-bind": "^1.0.2",
- "for-each": "^0.3.3",
- "gopd": "^1.0.1",
- "has-tostringtag": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/is-weakref": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/isexe": {
- "version": "2.0.0",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/jiti": {
- "version": "2.6.1",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jiti": "lib/jiti-cli.mjs"
- }
- },
- "node_modules/js-yaml": {
- "version": "4.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/json-parse-better-errors": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-stable-stringify-without-jsonify": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/jsonc-parser": {
- "version": "3.2.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/klona": {
- "version": "2.0.6",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/knitwork": {
- "version": "1.3.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/levn": {
- "version": "0.4.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1",
- "type-check": "~0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/load-json-file": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.1.2",
- "parse-json": "^4.0.0",
- "pify": "^3.0.0",
- "strip-bom": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/local-pkg": {
- "version": "0.5.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mlly": "^1.4.2",
- "pkg-types": "^1.0.3"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/locate-path": {
- "version": "6.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-locate": "^5.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lodash": {
- "version": "4.17.21",
- "license": "MIT"
- },
- "node_modules/lodash-es": {
- "version": "4.17.21",
- "license": "MIT"
- },
- "node_modules/lodash-unified": {
- "version": "1.0.3",
- "license": "MIT",
- "peerDependencies": {
- "@types/lodash-es": "*",
- "lodash": "*",
- "lodash-es": "*"
- }
- },
- "node_modules/lodash.merge": {
- "version": "4.6.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/mdn-data": {
- "version": "2.0.30",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/memoize-one": {
- "version": "6.0.0",
- "license": "MIT"
- },
- "node_modules/memorystream": {
- "version": "0.3.1",
- "dev": true,
- "engines": {
- "node": ">= 0.10.0"
- }
- },
- "node_modules/merge2": {
- "version": "1.4.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/micromatch": {
- "version": "4.0.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "braces": "^3.0.2",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=8.6"
- }
- },
- "node_modules/minimatch": {
- "version": "3.1.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/mlly": {
- "version": "1.8.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.15.0",
- "pathe": "^2.0.3",
- "pkg-types": "^1.3.1",
- "ufo": "^1.6.1"
- }
- },
- "node_modules/mlly/node_modules/confbox": {
- "version": "0.1.8",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/mlly/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/mlly/node_modules/pkg-types": {
- "version": "1.3.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.1.8",
- "mlly": "^1.7.4",
- "pathe": "^2.0.1"
- }
- },
- "node_modules/ms": {
- "version": "2.1.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/muggle-string": {
- "version": "0.4.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.11",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/natural-compare": {
- "version": "1.4.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nice-try": {
- "version": "1.0.5",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/node-addon-api": {
- "version": "7.1.1",
- "dev": true,
- "license": "MIT",
- "optional": true
- },
- "node_modules/node-fetch-native": {
- "version": "1.6.7",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/normalize-package-data": {
- "version": "2.5.0",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "hosted-git-info": "^2.1.4",
- "resolve": "^1.10.0",
- "semver": "2 || 3 || 4 || 5",
- "validate-npm-package-license": "^3.0.1"
- }
- },
- "node_modules/normalize-package-data/node_modules/is-core-module": {
- "version": "2.11.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has": "^1.0.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/normalize-package-data/node_modules/resolve": {
- "version": "1.22.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-core-module": "^2.9.0",
- "path-parse": "^1.0.7",
- "supports-preserve-symlinks-flag": "^1.0.0"
- },
- "bin": {
- "resolve": "bin/resolve"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/normalize-package-data/node_modules/semver": {
- "version": "5.7.1",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver"
- }
- },
- "node_modules/normalize-path": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/normalize-wheel-es": {
- "version": "1.2.0",
- "license": "BSD-3-Clause"
- },
- "node_modules/npm-run-all": {
- "version": "4.1.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^3.2.1",
- "chalk": "^2.4.1",
- "cross-spawn": "^6.0.5",
- "memorystream": "^0.3.1",
- "minimatch": "^3.0.4",
- "pidtree": "^0.3.0",
- "read-pkg": "^3.0.0",
- "shell-quote": "^1.6.1",
- "string.prototype.padend": "^3.0.0"
- },
- "bin": {
- "npm-run-all": "bin/npm-run-all/index.js",
- "run-p": "bin/run-p/index.js",
- "run-s": "bin/run-s/index.js"
- },
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/nth-check": {
- "version": "2.1.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "boolbase": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/fb55/nth-check?sponsor=1"
- }
- },
- "node_modules/nypm": {
- "version": "0.6.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "citty": "^0.1.6",
- "consola": "^3.4.2",
- "pathe": "^2.0.3",
- "pkg-types": "^2.3.0",
- "tinyexec": "^1.0.1"
- },
- "bin": {
- "nypm": "dist/cli.mjs"
- },
- "engines": {
- "node": "^14.16.0 || >=16.10.0"
- }
- },
- "node_modules/nypm/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nypm/node_modules/pkg-types": {
- "version": "2.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
- "pathe": "^2.0.3"
- }
- },
- "node_modules/object-inspect": {
- "version": "1.12.3",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/object-keys": {
- "version": "1.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/object.assign": {
- "version": "4.1.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.4",
- "has-symbols": "^1.0.3",
- "object-keys": "^1.1.1"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/ohash": {
- "version": "2.0.11",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/once": {
- "version": "1.4.0",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "wrappy": "1"
- }
- },
- "node_modules/optionator": {
- "version": "0.9.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@aashutoshrathi/word-wrap": "^1.2.3",
- "deep-is": "^0.1.3",
- "fast-levenshtein": "^2.0.6",
- "levn": "^0.4.1",
- "prelude-ls": "^1.2.1",
- "type-check": "^0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/p-limit": {
- "version": "3.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "yocto-queue": "^0.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-locate": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-limit": "^3.0.2"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/parent-module": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "callsites": "^3.0.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/parse-json": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "error-ex": "^1.3.1",
- "json-parse-better-errors": "^1.0.1"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/path-browserify": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/path-exists": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/path-is-absolute": {
- "version": "1.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/path-key": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/path-parse": {
- "version": "1.0.7",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/path-type": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "pify": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/pathe": {
- "version": "1.1.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/perfect-debounce": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "2.3.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/pidtree": {
- "version": "0.3.1",
- "dev": true,
- "license": "MIT",
- "bin": {
- "pidtree": "bin/pidtree.js"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/pify": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/pkg-types": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "jsonc-parser": "^3.2.0",
- "mlly": "^1.2.0",
- "pathe": "^1.1.0"
- }
- },
- "node_modules/pkg-types/node_modules/pathe": {
- "version": "1.1.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/postcss": {
- "version": "8.5.6",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.11",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/postcss-selector-parser": {
- "version": "6.0.15",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cssesc": "^3.0.0",
- "util-deprecate": "^1.0.2"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/prelude-ls": {
- "version": "1.2.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/prettier": {
- "version": "3.7.4",
- "dev": true,
- "license": "MIT",
- "bin": {
- "prettier": "bin/prettier.cjs"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
- "node_modules/prettier-linter-helpers": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-diff": "^1.1.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
- "node_modules/rc9": {
- "version": "2.1.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "defu": "^6.1.4",
- "destr": "^2.0.3"
- }
- },
- "node_modules/read-pkg": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "load-json-file": "^4.0.0",
- "normalize-package-data": "^2.3.2",
- "path-type": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/readdirp": {
- "version": "3.6.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
- },
- "node_modules/regexp.prototype.flags": {
- "version": "1.4.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.3",
- "functions-have-names": "^1.2.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/resolve": {
- "version": "1.22.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-core-module": "^2.13.0",
- "path-parse": "^1.0.7",
- "supports-preserve-symlinks-flag": "^1.0.0"
- },
- "bin": {
- "resolve": "bin/resolve"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/resolve-from": {
- "version": "4.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/reusify": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
- "node_modules/rimraf": {
- "version": "3.0.2",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/rolldown-string": {
- "version": "0.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "magic-string": "^0.30.21"
- },
- "engines": {
- "node": ">=20.19.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sxzz"
- }
- },
- "node_modules/rollup": {
- "version": "4.55.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.8"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.55.1",
- "@rollup/rollup-android-arm64": "4.55.1",
- "@rollup/rollup-darwin-arm64": "4.55.1",
- "@rollup/rollup-darwin-x64": "4.55.1",
- "@rollup/rollup-freebsd-arm64": "4.55.1",
- "@rollup/rollup-freebsd-x64": "4.55.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.55.1",
- "@rollup/rollup-linux-arm64-gnu": "4.55.1",
- "@rollup/rollup-linux-arm64-musl": "4.55.1",
- "@rollup/rollup-linux-loong64-gnu": "4.55.1",
- "@rollup/rollup-linux-loong64-musl": "4.55.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.55.1",
- "@rollup/rollup-linux-ppc64-musl": "4.55.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.55.1",
- "@rollup/rollup-linux-riscv64-musl": "4.55.1",
- "@rollup/rollup-linux-s390x-gnu": "4.55.1",
- "@rollup/rollup-linux-x64-gnu": "4.55.1",
- "@rollup/rollup-linux-x64-musl": "4.55.1",
- "@rollup/rollup-openbsd-x64": "4.55.1",
- "@rollup/rollup-openharmony-arm64": "4.55.1",
- "@rollup/rollup-win32-arm64-msvc": "4.55.1",
- "@rollup/rollup-win32-ia32-msvc": "4.55.1",
- "@rollup/rollup-win32-x64-gnu": "4.55.1",
- "@rollup/rollup-win32-x64-msvc": "4.55.1",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
- "node_modules/safe-regex-test": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "get-intrinsic": "^1.1.3",
- "is-regex": "^1.1.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/sass": {
- "version": "1.97.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "chokidar": "^4.0.0",
- "immutable": "^5.0.2",
- "source-map-js": ">=0.6.2 <2.0.0"
- },
- "bin": {
- "sass": "sass.js"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "optionalDependencies": {
- "@parcel/watcher": "^2.4.1"
- }
- },
- "node_modules/sass/node_modules/chokidar": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "readdirp": "^4.0.1"
- },
- "engines": {
- "node": ">= 14.16.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/sass/node_modules/readdirp": {
- "version": "4.1.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.18.0"
- },
- "funding": {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/scule": {
- "version": "1.3.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/semver": {
- "version": "7.7.3",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/shebang-command": {
- "version": "1.2.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^1.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/shebang-regex": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/shell-quote": {
- "version": "1.8.0",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/side-channel": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.0",
- "get-intrinsic": "^1.0.2",
- "object-inspect": "^1.9.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/slash": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/source-map": {
- "version": "0.6.1",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/source-map-support": {
- "version": "0.5.21",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- },
- "node_modules/spdx-correct": {
- "version": "3.1.1",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "spdx-expression-parse": "^3.0.0",
- "spdx-license-ids": "^3.0.0"
- }
- },
- "node_modules/spdx-exceptions": {
- "version": "2.3.0",
- "dev": true,
- "license": "CC-BY-3.0"
- },
- "node_modules/spdx-expression-parse": {
- "version": "3.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "spdx-exceptions": "^2.1.0",
- "spdx-license-ids": "^3.0.0"
- }
- },
- "node_modules/spdx-license-ids": {
- "version": "3.0.12",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/string.prototype.padend": {
- "version": "3.1.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.4",
- "es-abstract": "^1.20.4"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/string.prototype.trimend": {
- "version": "1.0.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.4",
- "es-abstract": "^1.20.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/string.prototype.trimstart": {
- "version": "1.0.6",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.4",
- "es-abstract": "^1.20.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-bom": {
- "version": "3.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/strip-json-comments": {
- "version": "3.1.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/strip-literal": {
- "version": "1.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.10.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/supports-color": {
- "version": "5.5.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-flag": "^3.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/supports-preserve-symlinks-flag": {
- "version": "1.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/svgo": {
- "version": "3.3.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@trysound/sax": "0.2.0",
- "commander": "^7.2.0",
- "css-select": "^5.1.0",
- "css-tree": "^2.3.1",
- "css-what": "^6.1.0",
- "csso": "^5.0.5",
- "picocolors": "^1.0.0"
- },
- "bin": {
- "svgo": "bin/svgo"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/svgo"
- }
- },
- "node_modules/svgo/node_modules/commander": {
- "version": "7.2.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/synckit": {
- "version": "0.8.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@pkgr/core": "^0.1.0",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- }
- },
- "node_modules/terser": {
- "version": "5.44.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@jridgewell/source-map": "^0.3.3",
- "acorn": "^8.15.0",
- "commander": "^2.20.0",
- "source-map-support": "~0.5.20"
- },
- "bin": {
- "terser": "bin/terser"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/text-table": {
- "version": "0.2.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinyexec": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.15",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.5.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
- "node_modules/ts-api-utils": {
- "version": "1.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=16.13.0"
- },
- "peerDependencies": {
- "typescript": ">=4.2.0"
- }
- },
- "node_modules/tslib": {
- "version": "2.6.2",
- "dev": true,
- "license": "0BSD"
- },
- "node_modules/type-check": {
- "version": "0.4.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/type-fest": {
- "version": "0.20.2",
- "dev": true,
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/typed-array-length": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "for-each": "^0.3.3",
- "is-typed-array": "^1.1.9"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "devOptional": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/ufo": {
- "version": "1.6.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unbox-primitive": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.2",
- "has-bigints": "^1.0.2",
- "has-symbols": "^1.0.3",
- "which-boxed-primitive": "^1.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/unctx": {
- "version": "2.5.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.15.0",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21",
- "unplugin": "^2.3.11"
- }
- },
- "node_modules/unctx/node_modules/estree-walker": {
- "version": "3.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/unctx/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/unctx/node_modules/unplugin": {
- "version": "2.3.11",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.5",
- "acorn": "^8.15.0",
- "picomatch": "^4.0.3",
- "webpack-virtual-modules": "^0.6.2"
- },
- "engines": {
- "node": ">=18.12.0"
- }
- },
- "node_modules/undici-types": {
- "version": "7.16.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unimport": {
- "version": "3.7.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rollup/pluginutils": "^5.1.0",
- "acorn": "^8.11.2",
- "escape-string-regexp": "^5.0.0",
- "estree-walker": "^3.0.3",
- "fast-glob": "^3.3.2",
- "local-pkg": "^0.5.0",
- "magic-string": "^0.30.5",
- "mlly": "^1.4.2",
- "pathe": "^1.1.1",
- "pkg-types": "^1.0.3",
- "scule": "^1.1.1",
- "strip-literal": "^1.3.0",
- "unplugin": "^1.5.1"
- }
- },
- "node_modules/unimport/node_modules/escape-string-regexp": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/unimport/node_modules/estree-walker": {
- "version": "3.0.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/unplugin": {
- "version": "1.6.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.11.2",
- "chokidar": "^3.5.3",
- "webpack-sources": "^3.2.3",
- "webpack-virtual-modules": "^0.6.1"
- }
- },
- "node_modules/unplugin-auto-import": {
- "version": "0.17.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@antfu/utils": "^0.7.7",
- "@rollup/pluginutils": "^5.1.0",
- "fast-glob": "^3.3.2",
- "local-pkg": "^0.5.0",
- "magic-string": "^0.30.5",
- "minimatch": "^9.0.3",
- "unimport": "^3.7.1",
- "unplugin": "^1.6.0"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@nuxt/kit": "^3.2.2",
- "@vueuse/core": "*"
- },
- "peerDependenciesMeta": {
- "@nuxt/kit": {
- "optional": true
- },
- "@vueuse/core": {
- "optional": true
- }
- }
- },
- "node_modules/unplugin-auto-import/node_modules/brace-expansion": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/unplugin-auto-import/node_modules/minimatch": {
- "version": "9.0.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/unplugin-element-plus": {
- "version": "0.11.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nuxt/kit": "^4.2.2",
- "es-module-lexer": "^2.0.0",
- "escape-string-regexp": "^5.0.0",
- "rolldown-string": "^0.2.1",
- "unplugin": "^2.3.11"
- },
- "engines": {
- "node": ">=20.19.0"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/@nuxt/kit": {
- "version": "4.2.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "c12": "^3.3.2",
- "consola": "^3.4.2",
- "defu": "^6.1.4",
- "destr": "^2.0.5",
- "errx": "^0.1.0",
- "exsolve": "^1.0.8",
- "ignore": "^7.0.5",
- "jiti": "^2.6.1",
- "klona": "^2.0.6",
- "mlly": "^1.8.0",
- "ohash": "^2.0.11",
- "pathe": "^2.0.3",
- "pkg-types": "^2.3.0",
- "rc9": "^2.1.2",
- "scule": "^1.3.0",
- "semver": "^7.7.3",
- "tinyglobby": "^0.2.15",
- "ufo": "^1.6.1",
- "unctx": "^2.4.1",
- "untyped": "^2.0.0"
- },
- "engines": {
- "node": ">=18.12.0"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/escape-string-regexp": {
- "version": "5.0.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/ignore": {
- "version": "7.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/pathe": {
- "version": "2.0.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unplugin-element-plus/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/pkg-types": {
- "version": "2.3.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.2.2",
- "exsolve": "^1.0.7",
- "pathe": "^2.0.3"
- }
- },
- "node_modules/unplugin-element-plus/node_modules/unplugin": {
- "version": "2.3.11",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.5",
- "acorn": "^8.15.0",
- "picomatch": "^4.0.3",
- "webpack-virtual-modules": "^0.6.2"
- },
- "engines": {
- "node": ">=18.12.0"
- }
- },
- "node_modules/unplugin-vue-components": {
- "version": "0.26.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@antfu/utils": "^0.7.6",
- "@rollup/pluginutils": "^5.0.4",
- "chokidar": "^3.5.3",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.1",
- "local-pkg": "^0.4.3",
- "magic-string": "^0.30.3",
- "minimatch": "^9.0.3",
- "resolve": "^1.22.4",
- "unplugin": "^1.4.0"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@babel/parser": "^7.15.8",
- "@nuxt/kit": "^3.2.2",
- "vue": "2 || 3"
- },
- "peerDependenciesMeta": {
- "@babel/parser": {
- "optional": true
- },
- "@nuxt/kit": {
- "optional": true
- }
- }
- },
- "node_modules/unplugin-vue-components/node_modules/brace-expansion": {
- "version": "2.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/unplugin-vue-components/node_modules/local-pkg": {
- "version": "0.4.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/unplugin-vue-components/node_modules/minimatch": {
- "version": "9.0.3",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/untyped": {
- "version": "2.0.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "citty": "^0.1.6",
- "defu": "^6.1.4",
- "jiti": "^2.4.2",
- "knitwork": "^1.2.0",
- "scule": "^1.3.0"
- },
- "bin": {
- "untyped": "dist/cli.mjs"
- }
- },
- "node_modules/uri-js": {
- "version": "4.4.1",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
- "node_modules/util-deprecate": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/validate-npm-package-license": {
- "version": "3.0.4",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "spdx-correct": "^3.0.0",
- "spdx-expression-parse": "^3.0.0"
- }
- },
- "node_modules/vite": {
- "version": "7.3.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "^0.27.0",
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.6",
- "rollup": "^4.43.0",
- "tinyglobby": "^0.2.15"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "lightningcss": "^1.21.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vite-svg-loader": {
- "version": "5.1.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "svgo": "^3.0.2"
- },
- "peerDependencies": {
- "vue": ">=3.2.13"
- }
- },
- "node_modules/vite/node_modules/fdir": {
- "version": "6.5.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/vite/node_modules/fsevents": {
- "version": "2.3.3",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/vite/node_modules/picomatch": {
- "version": "4.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/vscode-uri": {
- "version": "3.1.0",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/vue": {
- "version": "3.5.26",
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-dom": "3.5.26",
- "@vue/compiler-sfc": "3.5.26",
- "@vue/runtime-dom": "3.5.26",
- "@vue/server-renderer": "3.5.26",
- "@vue/shared": "3.5.26"
- },
- "peerDependencies": {
- "typescript": "*"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/vue-eslint-parser": {
- "version": "9.4.3",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.3.4",
- "eslint-scope": "^7.1.1",
- "eslint-visitor-keys": "^3.3.0",
- "espree": "^9.3.1",
- "esquery": "^1.4.0",
- "lodash": "^4.17.21",
- "semver": "^7.3.6"
- },
- "engines": {
- "node": "^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/mysticatea"
- },
- "peerDependencies": {
- "eslint": ">=6.0.0"
- }
- },
- "node_modules/vue-router": {
- "version": "4.6.4",
- "license": "MIT",
- "dependencies": {
- "@vue/devtools-api": "^6.6.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/posva"
- },
- "peerDependencies": {
- "vue": "^3.5.0"
- }
- },
- "node_modules/vue-tsc": {
- "version": "3.2.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/typescript": "2.4.27",
- "@vue/language-core": "3.2.2"
- },
- "bin": {
- "vue-tsc": "bin/vue-tsc.js"
- },
- "peerDependencies": {
- "typescript": ">=5.0.0"
- }
- },
- "node_modules/webpack-sources": {
- "version": "3.2.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/webpack-virtual-modules": {
- "version": "0.6.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/which": {
- "version": "1.3.1",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "which": "bin/which"
- }
- },
- "node_modules/which-boxed-primitive": {
- "version": "1.0.2",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-bigint": "^1.0.1",
- "is-boolean-object": "^1.1.0",
- "is-number-object": "^1.0.4",
- "is-string": "^1.0.5",
- "is-symbol": "^1.0.3"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/which-typed-array": {
- "version": "1.1.9",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "available-typed-arrays": "^1.0.5",
- "call-bind": "^1.0.2",
- "for-each": "^0.3.3",
- "gopd": "^1.0.1",
- "has-tostringtag": "^1.0.0",
- "is-typed-array": "^1.1.10"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/wrappy": {
- "version": "1.0.2",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/xml-name-validator": {
- "version": "4.0.0",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/yocto-queue": {
- "version": "0.1.0",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- }
- }
-}
diff --git a/web/frpc/package.json b/web/frpc/package.json
index b41d048a..6e20d893 100644
--- a/web/frpc/package.json
+++ b/web/frpc/package.json
@@ -2,28 +2,29 @@
"name": "frpc-dashboard",
"version": "0.0.1",
"private": true,
+ "type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit",
- "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
+ "lint": "eslint --fix"
},
"dependencies": {
"element-plus": "^2.13.0",
+ "pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
- "@rushstack/eslint-patch": "^1.15.0",
"@types/node": "24",
"@vitejs/plugin-vue": "^6.0.3",
- "@vue/eslint-config-prettier": "^9.0.0",
- "@vue/eslint-config-typescript": "^12.0.0",
+ "@vue/eslint-config-prettier": "^10.2.0",
+ "@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
- "eslint": "^8.56.0",
+ "eslint": "^9.39.0",
"eslint-plugin-vue": "^9.33.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.7.4",
@@ -37,4 +38,4 @@
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.2"
}
-}
\ No newline at end of file
+}
diff --git a/web/frpc/src/App.vue b/web/frpc/src/App.vue
index 2bfded92..b75c8a69 100644
--- a/web/frpc/src/App.vue
+++ b/web/frpc/src/App.vue
@@ -2,134 +2,160 @@