forked from Mxmilu666/frp
Compare commits
9 Commits
v0.67.0
...
copilot/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7993225be1 | ||
|
|
17b27d8d96 | ||
|
|
541878af4d | ||
|
|
b7435967b0 | ||
|
|
774478d071 | ||
|
|
fbeb6ca43a | ||
|
|
381245a439 | ||
|
|
01997deb98 | ||
|
|
d0347325fc |
@@ -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
|
||||
|
||||
6
.github/workflows/golangci-lint.yml
vendored
6
.github/workflows/golangci-lint.yml
vendored
@@ -17,7 +17,7 @@ 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:
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
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
|
||||
|
||||
2
.github/workflows/goreleaser.yml
vendored
2
.github/workflows/goreleaser.yml
vendored
@@ -15,7 +15,7 @@ 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'
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -7,18 +7,6 @@
|
||||
_obj
|
||||
_test
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
@@ -42,3 +30,5 @@ client.key
|
||||
|
||||
# AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.sisyphus/
|
||||
|
||||
@@ -33,13 +33,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:
|
||||
@@ -77,6 +71,9 @@ linters:
|
||||
- linters:
|
||||
- revive
|
||||
text: "avoid meaningless package names"
|
||||
- linters:
|
||||
- revive
|
||||
text: "Go standard library package names"
|
||||
- linters:
|
||||
- unparam
|
||||
text: is always false
|
||||
|
||||
21
Makefile
21
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
|
||||
|
||||
27
README.md
27
README.md
@@ -13,6 +13,16 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<div align="center">
|
||||
|
||||
## 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.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
||||
@@ -40,15 +50,6 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
## 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.
|
||||
|
||||
</div>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## 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
|
||||
|
||||
19
README_zh.md
19
README_zh.md
@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<div align="center">
|
||||
|
||||
## 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.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
||||
@@ -42,15 +52,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
## 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.
|
||||
|
||||
</div>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## 为什么使用 frp ?
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
## 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.
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,20 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
||||
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
||||
|
||||
if svr.storeSource != nil {
|
||||
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreProxy)).Methods(http.MethodPost)
|
||||
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreProxy)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreProxy)).Methods(http.MethodPut)
|
||||
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreProxy)).Methods(http.MethodDelete)
|
||||
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreVisitors)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreVisitor)).Methods(http.MethodPost)
|
||||
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreVisitor)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreVisitor)).Methods(http.MethodPut)
|
||||
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreVisitor)).Methods(http.MethodDelete)
|
||||
}
|
||||
|
||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||
subRouter.PathPrefix("/static/").Handler(
|
||||
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
|
||||
@@ -51,14 +65,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,
|
||||
})
|
||||
}
|
||||
|
||||
422
client/config_manager.go
Normal file
422
client/config_manager.go
Normal file
@@ -0,0 +1,422 @@
|
||||
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) 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)
|
||||
}
|
||||
137
client/config_manager_test.go
Normal file
137
client/config_manager_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
42
client/configmgmt/types.go
Normal file
42
client/configmgmt/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
395
client/http/controller.go
Normal file
395
client/http/controller.go
Normal file
@@ -0,0 +1,395 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
531
client/http/controller_test.go
Normal file
531
client/http/controller_test.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/fatedier/frp/client/configmgmt"
|
||||
"github.com/fatedier/frp/client/http/model"
|
||||
"github.com/fatedier/frp/client/proxy"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
)
|
||||
|
||||
type fakeConfigManager struct {
|
||||
reloadFromFileFn func(strict bool) error
|
||||
readConfigFileFn func() (string, error)
|
||||
writeConfigFileFn func(content []byte) error
|
||||
getProxyStatusFn func() []*proxy.WorkingStatus
|
||||
isStoreProxyEnabledFn func(name string) bool
|
||||
storeEnabledFn func() bool
|
||||
|
||||
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
|
||||
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
|
||||
createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
||||
updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
|
||||
deleteStoreProxyFn func(name string) error
|
||||
listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error)
|
||||
getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error)
|
||||
createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
|
||||
updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
|
||||
deleteStoreVisitFn func(name string) error
|
||||
gracefulCloseFn func(d time.Duration)
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) ReloadFromFile(strict bool) error {
|
||||
if m.reloadFromFileFn != nil {
|
||||
return m.reloadFromFileFn(strict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) ReadConfigFile() (string, error) {
|
||||
if m.readConfigFileFn != nil {
|
||||
return m.readConfigFileFn()
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) WriteConfigFile(content []byte) error {
|
||||
if m.writeConfigFileFn != nil {
|
||||
return m.writeConfigFileFn(content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
|
||||
if m.getProxyStatusFn != nil {
|
||||
return m.getProxyStatusFn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) IsStoreProxyEnabled(name string) bool {
|
||||
if m.isStoreProxyEnabledFn != nil {
|
||||
return m.isStoreProxyEnabledFn(name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) StoreEnabled() bool {
|
||||
if m.storeEnabledFn != nil {
|
||||
return m.storeEnabledFn()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
|
||||
if m.listStoreProxiesFn != nil {
|
||||
return m.listStoreProxiesFn()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {
|
||||
if m.getStoreProxyFn != nil {
|
||||
return m.getStoreProxyFn(name)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
||||
if m.createStoreProxyFn != nil {
|
||||
return m.createStoreProxyFn(cfg)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
||||
if m.updateStoreProxyFn != nil {
|
||||
return m.updateStoreProxyFn(name, cfg)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) DeleteStoreProxy(name string) error {
|
||||
if m.deleteStoreProxyFn != nil {
|
||||
return m.deleteStoreProxyFn(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {
|
||||
if m.listStoreVisitorsFn != nil {
|
||||
return m.listStoreVisitorsFn()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {
|
||||
if m.getStoreVisitorFn != nil {
|
||||
return m.getStoreVisitorFn(name)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
|
||||
if m.createStoreVisitFn != nil {
|
||||
return m.createStoreVisitFn(cfg)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
|
||||
if m.updateStoreVisitFn != nil {
|
||||
return m.updateStoreVisitFn(name, cfg)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) DeleteStoreVisitor(name string) error {
|
||||
if m.deleteStoreVisitFn != nil {
|
||||
return m.deleteStoreVisitFn(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fakeConfigManager) GracefulClose(d time.Duration) {
|
||||
if m.gracefulCloseFn != nil {
|
||||
m.gracefulCloseFn(d)
|
||||
}
|
||||
}
|
||||
|
||||
func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
|
||||
return &v1.TCPProxyConfig{
|
||||
ProxyBaseConfig: v1.ProxyBaseConfig{
|
||||
Name: name,
|
||||
Type: "tcp",
|
||||
ProxyBackend: v1.ProxyBackend{
|
||||
LocalPort: 10080,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
|
||||
status := &proxy.WorkingStatus{
|
||||
Name: "shared-proxy",
|
||||
Type: "tcp",
|
||||
Phase: proxy.ProxyPhaseRunning,
|
||||
RemoteAddr: ":8080",
|
||||
Cfg: newRawTCPProxyConfig("shared-proxy"),
|
||||
}
|
||||
|
||||
controller := &Controller{
|
||||
serverAddr: "127.0.0.1",
|
||||
manager: &fakeConfigManager{
|
||||
isStoreProxyEnabledFn: func(name string) bool {
|
||||
return name == "shared-proxy"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := controller.buildProxyStatusResp(status)
|
||||
if resp.Source != "store" {
|
||||
t.Fatalf("unexpected source: %q", resp.Source)
|
||||
}
|
||||
if resp.RemoteAddr != "127.0.0.1:8080" {
|
||||
t.Fatalf("unexpected remote addr: %q", resp.RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadErrorMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expectedCode int
|
||||
}{
|
||||
{name: "invalid arg", err: fmtError(configmgmt.ErrInvalidArgument, "bad cfg"), expectedCode: http.StatusBadRequest},
|
||||
{name: "apply fail", err: fmtError(configmgmt.ErrApplyConfig, "reload failed"), expectedCode: http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{reloadFromFileFn: func(bool) error { return tc.err }},
|
||||
}
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/reload", nil))
|
||||
_, err := controller.Reload(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
assertHTTPCode(t, err, tc.expectedCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreProxyErrorMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expectedCode int
|
||||
}{
|
||||
{name: "not found", err: fmtError(configmgmt.ErrNotFound, "not found"), expectedCode: http.StatusNotFound},
|
||||
{name: "conflict", err: fmtError(configmgmt.ErrConflict, "exists"), expectedCode: http.StatusConflict},
|
||||
{name: "internal", err: errors.New("persist failed"), expectedCode: http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body))
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
||||
return nil, tc.err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := controller.UpdateStoreProxy(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
assertHTTPCode(t, err, tc.expectedCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreVisitorErrorMapping(t *testing.T) {
|
||||
body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body))
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
deleteStoreVisitFn: func(string) error {
|
||||
return fmtError(configmgmt.ErrStoreDisabled, "disabled")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := controller.DeleteStoreVisitor(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
assertHTTPCode(t, err, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) {
|
||||
var gotName string
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
||||
gotName = cfg.GetBaseConfig().Name
|
||||
return cfg, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body := []byte(`{"name":"raw-proxy","type":"tcp","unexpected":"value","tcp":{"localPort":10080,"unknownInBlock":"value"}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
resp, err := controller.CreateStoreProxy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create store proxy: %v", err)
|
||||
}
|
||||
if gotName != "raw-proxy" {
|
||||
t.Fatalf("unexpected proxy name: %q", gotName)
|
||||
}
|
||||
|
||||
payload, ok := resp.(model.ProxyDefinition)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type: %T", resp)
|
||||
}
|
||||
if payload.Type != "tcp" || payload.TCP == nil {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) {
|
||||
var gotName string
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
|
||||
gotName = cfg.GetBaseConfig().Name
|
||||
return cfg, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body := []byte(`{
|
||||
"name":"raw-visitor","type":"xtcp","unexpected":"value",
|
||||
"xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret","unknownInBlock":"value"}
|
||||
}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
resp, err := controller.CreateStoreVisitor(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create store visitor: %v", err)
|
||||
}
|
||||
if gotName != "raw-visitor" {
|
||||
t.Fatalf("unexpected visitor name: %q", gotName)
|
||||
}
|
||||
|
||||
payload, ok := resp.(model.VisitorDefinition)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type: %T", resp)
|
||||
}
|
||||
if payload.Type != "xtcp" || payload.XTCP == nil {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) {
|
||||
var gotPluginType string
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
||||
gotPluginType = cfg.GetBaseConfig().Plugin.Type
|
||||
return cfg, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body := []byte(`{"name":"plugin-proxy","type":"tcp","tcp":{"plugin":{"type":"http2https","localAddr":"127.0.0.1:8080","unknownInPlugin":"value"}}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
resp, err := controller.CreateStoreProxy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create store proxy: %v", err)
|
||||
}
|
||||
if gotPluginType != "http2https" {
|
||||
t.Fatalf("unexpected plugin type: %q", gotPluginType)
|
||||
}
|
||||
payload, ok := resp.(model.ProxyDefinition)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type: %T", resp)
|
||||
}
|
||||
if payload.TCP == nil {
|
||||
t.Fatalf("unexpected response payload: %#v", payload)
|
||||
}
|
||||
pluginType := payload.TCP.Plugin.Type
|
||||
|
||||
if pluginType != "http2https" {
|
||||
t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) {
|
||||
var gotPluginType string
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
|
||||
gotPluginType = cfg.GetBaseConfig().Plugin.Type
|
||||
return cfg, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body := []byte(`{
|
||||
"name":"plugin-visitor","type":"stcp",
|
||||
"stcp":{"serverName":"server","bindPort":10081,"plugin":{"type":"virtual_net","destinationIP":"10.0.0.1","unknownInPlugin":"value"}}
|
||||
}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
resp, err := controller.CreateStoreVisitor(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create store visitor: %v", err)
|
||||
}
|
||||
if gotPluginType != "virtual_net" {
|
||||
t.Fatalf("unexpected plugin type: %q", gotPluginType)
|
||||
}
|
||||
payload, ok := resp.(model.VisitorDefinition)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type: %T", resp)
|
||||
}
|
||||
if payload.STCP == nil {
|
||||
t.Fatalf("unexpected response payload: %#v", payload)
|
||||
}
|
||||
pluginType := payload.STCP.Plugin.Type
|
||||
|
||||
if pluginType != "virtual_net" {
|
||||
t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) {
|
||||
controller := &Controller{manager: &fakeConfigManager{}}
|
||||
body := []byte(`{"name":"p1","type":"tcp","udp":{"localPort":10080}}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "p1"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
_, err := controller.UpdateStoreProxy(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
assertHTTPCode(t, err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) {
|
||||
controller := &Controller{manager: &fakeConfigManager{}}
|
||||
body := []byte(`{"name":"p2","type":"tcp","tcp":{"localPort":10080}}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "p1"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
_, err := controller.UpdateStoreProxy(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
assertHTTPCode(t, err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestListStoreProxiesReturnsSortedPayload(t *testing.T) {
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
listStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) {
|
||||
b := newRawTCPProxyConfig("b")
|
||||
a := newRawTCPProxyConfig("a")
|
||||
return []v1.ProxyConfigurer{b, a}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/store/proxies", nil))
|
||||
|
||||
resp, err := controller.ListStoreProxies(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list store proxies: %v", err)
|
||||
}
|
||||
out, ok := resp.(model.ProxyListResp)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type: %T", resp)
|
||||
}
|
||||
if len(out.Proxies) != 2 {
|
||||
t.Fatalf("unexpected proxy count: %d", len(out.Proxies))
|
||||
}
|
||||
if out.Proxies[0].Name != "a" || out.Proxies[1].Name != "b" {
|
||||
t.Fatalf("proxies are not sorted by name: %#v", out.Proxies)
|
||||
}
|
||||
}
|
||||
|
||||
func fmtError(sentinel error, msg string) error {
|
||||
return fmt.Errorf("%w: %s", sentinel, msg)
|
||||
}
|
||||
|
||||
func assertHTTPCode(t *testing.T, err error, expected int) {
|
||||
t.Helper()
|
||||
var httpErr *httppkg.Error
|
||||
if !errors.As(err, &httpErr) {
|
||||
t.Fatalf("unexpected error type: %T", err)
|
||||
}
|
||||
if httpErr.Code != expected {
|
||||
t.Fatalf("unexpected status code: got %d, want %d", httpErr.Code, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
|
||||
controller := &Controller{
|
||||
manager: &fakeConfigManager{
|
||||
updateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"name": "shared-proxy",
|
||||
"type": "tcp",
|
||||
"tcp": map[string]any{
|
||||
"localPort": 10080,
|
||||
"remotePort": 7000,
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(data))
|
||||
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
|
||||
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
|
||||
|
||||
resp, err := controller.UpdateStoreProxy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("update store proxy: %v", err)
|
||||
}
|
||||
payload, ok := resp.(model.ProxyDefinition)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected response type: %T", resp)
|
||||
}
|
||||
if payload.TCP == nil || payload.TCP.RemotePort != 7000 {
|
||||
t.Fatalf("unexpected response payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
148
client/http/model/proxy_definition.go
Normal file
148
client/http/model/proxy_definition.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
107
client/http/model/visitor_definition.go
Normal file
107
client/http/model/visitor_definition.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -129,7 +129,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 +145,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")
|
||||
}
|
||||
|
||||
@@ -27,6 +27,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"
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
|
||||
"github.com/fatedier/frp/client/proxy"
|
||||
"github.com/fatedier/frp/pkg/auth"
|
||||
"github.com/fatedier/frp/pkg/config"
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
@@ -61,9 +63,11 @@ func (e cancelErr) Error() string {
|
||||
|
||||
// ServiceOptions contains options for creating a new client service.
|
||||
type ServiceOptions struct {
|
||||
Common *v1.ClientCommonConfig
|
||||
ProxyCfgs []v1.ProxyConfigurer
|
||||
VisitorCfgs []v1.VisitorConfigurer
|
||||
Common *v1.ClientCommonConfig
|
||||
|
||||
// ConfigSourceAggregator manages internal config and optional store sources.
|
||||
// It is required for creating a Service.
|
||||
ConfigSourceAggregator *source.Aggregator
|
||||
|
||||
UnsafeFeatures *security.UnsafeFeatures
|
||||
|
||||
@@ -119,11 +123,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
|
||||
|
||||
@@ -160,19 +176,39 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if options.ConfigSourceAggregator == nil {
|
||||
return nil, fmt.Errorf("config source aggregator is required")
|
||||
}
|
||||
|
||||
configSource := options.ConfigSourceAggregator.ConfigSource()
|
||||
storeSource := options.ConfigSourceAggregator.StoreSource()
|
||||
|
||||
proxyCfgs, visitorCfgs, loadErr := options.ConfigSourceAggregator.Load()
|
||||
if loadErr != nil {
|
||||
return nil, fmt.Errorf("failed to load config from aggregator: %w", loadErr)
|
||||
}
|
||||
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(options.Common, proxyCfgs, visitorCfgs)
|
||||
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
|
||||
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
|
||||
|
||||
s := &Service{
|
||||
ctx: context.Background(),
|
||||
auth: authRuntime,
|
||||
webServer: webServer,
|
||||
common: options.Common,
|
||||
reloadCommon: options.Common,
|
||||
configFilePath: options.ConfigFilePath,
|
||||
unsafeFeatures: options.UnsafeFeatures,
|
||||
proxyCfgs: options.ProxyCfgs,
|
||||
visitorCfgs: options.VisitorCfgs,
|
||||
proxyCfgs: proxyCfgs,
|
||||
visitorCfgs: visitorCfgs,
|
||||
clientSpec: options.ClientSpec,
|
||||
aggregator: options.ConfigSourceAggregator,
|
||||
configSource: configSource,
|
||||
storeSource: storeSource,
|
||||
connectorCreator: options.ConnectorCreator,
|
||||
handleWorkConnCb: options.HandleWorkConnCb,
|
||||
}
|
||||
|
||||
if webServer != nil {
|
||||
webServer.RouteRegister(s.registerRouteHandlers)
|
||||
}
|
||||
@@ -403,6 +439,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 +478,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 {
|
||||
@@ -453,3 +527,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
|
||||
}
|
||||
|
||||
140
client/service_test.go
Normal file
140
client/service_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config/source"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,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/util/util"
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
@@ -103,9 +104,10 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
|
||||
defer visitorConn.Close()
|
||||
|
||||
now := time.Now().Unix()
|
||||
targetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
|
||||
newVisitorConnMsg := &msg.NewVisitorConn{
|
||||
RunID: sv.helper.RunID(),
|
||||
ProxyName: sv.cfg.ServerName,
|
||||
ProxyName: targetProxyName,
|
||||
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
||||
Timestamp: now,
|
||||
UseEncryption: sv.cfg.Transport.UseEncryption,
|
||||
|
||||
@@ -27,6 +27,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/proto/udp"
|
||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||
"github.com/fatedier/frp/pkg/util/util"
|
||||
@@ -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
|
||||
}
|
||||
@@ -205,9 +206,10 @@ func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) {
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
targetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
|
||||
newVisitorConnMsg := &msg.NewVisitorConn{
|
||||
RunID: sv.helper.RunID(),
|
||||
ProxyName: sv.cfg.ServerName,
|
||||
ProxyName: targetProxyName,
|
||||
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
|
||||
Timestamp: now,
|
||||
UseEncryption: sv.cfg.Transport.UseEncryption,
|
||||
|
||||
@@ -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"
|
||||
@@ -280,8 +281,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 +312,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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
go.mod
2
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
117
pkg/config/source/aggregator.go
Normal file
117
pkg/config/source/aggregator.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
type 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 := make([]v1.ProxyConfigurer, 0, len(proxyMap))
|
||||
for _, p := range proxyMap {
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
sort.Slice(proxies, func(i, j int) bool {
|
||||
return proxies[i].GetBaseConfig().Name < proxies[j].GetBaseConfig().Name
|
||||
})
|
||||
|
||||
visitors := make([]v1.VisitorConfigurer, 0, len(visitorMap))
|
||||
for _, v := range visitorMap {
|
||||
visitors = append(visitors, v)
|
||||
}
|
||||
sort.Slice(visitors, func(i, j int) bool {
|
||||
return visitors[i].GetBaseConfig().Name < visitors[j].GetBaseConfig().Name
|
||||
})
|
||||
|
||||
return proxies, visitors
|
||||
}
|
||||
217
pkg/config/source/aggregator_test.go
Normal file
217
pkg/config/source/aggregator_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
// mockProxy creates a TCP proxy config for testing
|
||||
func mockProxy(name string) v1.ProxyConfigurer {
|
||||
cfg := &v1.TCPProxyConfig{}
|
||||
cfg.Name = name
|
||||
cfg.Type = "tcp"
|
||||
cfg.LocalPort = 8080
|
||||
cfg.RemotePort = 9090
|
||||
return cfg
|
||||
}
|
||||
|
||||
// mockVisitor creates a STCP visitor config for testing
|
||||
func mockVisitor(name string) v1.VisitorConfigurer {
|
||||
cfg := &v1.STCPVisitorConfig{}
|
||||
cfg.Name = name
|
||||
cfg.Type = "stcp"
|
||||
cfg.ServerName = "test-server"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func newTestStoreSource(t *testing.T) *StoreSource {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "store.json")
|
||||
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
|
||||
require.NoError(t, err)
|
||||
return storeSource
|
||||
}
|
||||
|
||||
func newTestAggregator(t *testing.T, storeSource *StoreSource) *Aggregator {
|
||||
t.Helper()
|
||||
|
||||
configSource := NewConfigSource()
|
||||
agg := NewAggregator(configSource)
|
||||
if storeSource != nil {
|
||||
agg.SetStoreSource(storeSource)
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
func TestNewAggregator_CreatesConfigSourceWhenNil(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
agg := NewAggregator(nil)
|
||||
require.NotNil(agg)
|
||||
require.NotNil(agg.ConfigSource())
|
||||
require.Nil(agg.StoreSource())
|
||||
}
|
||||
|
||||
func TestNewAggregator_WithoutStore(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
configSource := NewConfigSource()
|
||||
agg := NewAggregator(configSource)
|
||||
require.NotNil(agg)
|
||||
require.Same(configSource, agg.ConfigSource())
|
||||
require.Nil(agg.StoreSource())
|
||||
}
|
||||
|
||||
func TestNewAggregator_WithStore(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
storeSource := newTestStoreSource(t)
|
||||
configSource := NewConfigSource()
|
||||
agg := NewAggregator(configSource)
|
||||
agg.SetStoreSource(storeSource)
|
||||
|
||||
require.Same(configSource, agg.ConfigSource())
|
||||
require.Same(storeSource, agg.StoreSource())
|
||||
}
|
||||
|
||||
func TestAggregator_SetStoreSource_Overwrite(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
agg := newTestAggregator(t, nil)
|
||||
first := newTestStoreSource(t)
|
||||
second := newTestStoreSource(t)
|
||||
|
||||
agg.SetStoreSource(first)
|
||||
require.Same(first, agg.StoreSource())
|
||||
|
||||
agg.SetStoreSource(second)
|
||||
require.Same(second, agg.StoreSource())
|
||||
|
||||
agg.SetStoreSource(nil)
|
||||
require.Nil(agg.StoreSource())
|
||||
}
|
||||
|
||||
func TestAggregator_MergeBySourceOrder(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
storeSource := newTestStoreSource(t)
|
||||
agg := newTestAggregator(t, storeSource)
|
||||
|
||||
configSource := agg.ConfigSource()
|
||||
|
||||
configShared := mockProxy("shared").(*v1.TCPProxyConfig)
|
||||
configShared.LocalPort = 1111
|
||||
configOnly := mockProxy("only-in-config").(*v1.TCPProxyConfig)
|
||||
configOnly.LocalPort = 1112
|
||||
|
||||
err := configSource.ReplaceAll([]v1.ProxyConfigurer{configShared, configOnly}, nil)
|
||||
require.NoError(err)
|
||||
|
||||
storeShared := mockProxy("shared").(*v1.TCPProxyConfig)
|
||||
storeShared.LocalPort = 2222
|
||||
storeOnly := mockProxy("only-in-store").(*v1.TCPProxyConfig)
|
||||
storeOnly.LocalPort = 2223
|
||||
err = storeSource.AddProxy(storeShared)
|
||||
require.NoError(err)
|
||||
err = storeSource.AddProxy(storeOnly)
|
||||
require.NoError(err)
|
||||
|
||||
proxies, visitors, err := agg.Load()
|
||||
require.NoError(err)
|
||||
require.Len(visitors, 0)
|
||||
require.Len(proxies, 3)
|
||||
|
||||
var sharedProxy *v1.TCPProxyConfig
|
||||
for _, p := range proxies {
|
||||
if p.GetBaseConfig().Name == "shared" {
|
||||
sharedProxy = p.(*v1.TCPProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(sharedProxy)
|
||||
require.Equal(2222, sharedProxy.LocalPort)
|
||||
}
|
||||
|
||||
func TestAggregator_DisabledEntryIsSourceLocalFilter(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
storeSource := newTestStoreSource(t)
|
||||
agg := newTestAggregator(t, storeSource)
|
||||
configSource := agg.ConfigSource()
|
||||
|
||||
lowProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
|
||||
lowProxy.LocalPort = 1111
|
||||
err := configSource.ReplaceAll([]v1.ProxyConfigurer{lowProxy}, nil)
|
||||
require.NoError(err)
|
||||
|
||||
disabled := false
|
||||
highProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
|
||||
highProxy.LocalPort = 2222
|
||||
highProxy.Enabled = &disabled
|
||||
err = storeSource.AddProxy(highProxy)
|
||||
require.NoError(err)
|
||||
|
||||
proxies, visitors, err := agg.Load()
|
||||
require.NoError(err)
|
||||
require.Len(proxies, 1)
|
||||
require.Len(visitors, 0)
|
||||
|
||||
proxy := proxies[0].(*v1.TCPProxyConfig)
|
||||
require.Equal("shared-proxy", proxy.Name)
|
||||
require.Equal(1111, proxy.LocalPort)
|
||||
}
|
||||
|
||||
func TestAggregator_VisitorMerge(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
storeSource := newTestStoreSource(t)
|
||||
agg := newTestAggregator(t, storeSource)
|
||||
|
||||
err := agg.ConfigSource().ReplaceAll(nil, []v1.VisitorConfigurer{mockVisitor("visitor1")})
|
||||
require.NoError(err)
|
||||
err = storeSource.AddVisitor(mockVisitor("visitor2"))
|
||||
require.NoError(err)
|
||||
|
||||
_, visitors, err := agg.Load()
|
||||
require.NoError(err)
|
||||
require.Len(visitors, 2)
|
||||
}
|
||||
|
||||
func TestAggregator_Load_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)
|
||||
}
|
||||
65
pkg/config/source/base_source.go
Normal file
65
pkg/config/source/base_source.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
// baseSource provides shared state and behavior for Source implementations.
|
||||
// It manages proxy/visitor storage.
|
||||
// Concrete types (ConfigSource, StoreSource) embed this struct.
|
||||
type baseSource struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
proxies map[string]v1.ProxyConfigurer
|
||||
visitors map[string]v1.VisitorConfigurer
|
||||
}
|
||||
|
||||
func newBaseSource() baseSource {
|
||||
return baseSource{
|
||||
proxies: make(map[string]v1.ProxyConfigurer),
|
||||
visitors: make(map[string]v1.VisitorConfigurer),
|
||||
}
|
||||
}
|
||||
|
||||
// Load returns all enabled proxy and visitor configurations.
|
||||
// Configurations with Enabled explicitly set to false are filtered out.
|
||||
func (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
proxies := make([]v1.ProxyConfigurer, 0, len(s.proxies))
|
||||
for _, p := range s.proxies {
|
||||
// Filter out disabled proxies (nil or true means enabled)
|
||||
if enabled := p.GetBaseConfig().Enabled; enabled != nil && !*enabled {
|
||||
continue
|
||||
}
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
|
||||
visitors := make([]v1.VisitorConfigurer, 0, len(s.visitors))
|
||||
for _, v := range s.visitors {
|
||||
// Filter out disabled visitors (nil or true means enabled)
|
||||
if enabled := v.GetBaseConfig().Enabled; enabled != nil && !*enabled {
|
||||
continue
|
||||
}
|
||||
visitors = append(visitors, v)
|
||||
}
|
||||
|
||||
return cloneConfigurers(proxies, visitors)
|
||||
}
|
||||
48
pkg/config/source/base_source_test.go
Normal file
48
pkg/config/source/base_source_test.go
Normal file
@@ -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)
|
||||
}
|
||||
43
pkg/config/source/clone.go
Normal file
43
pkg/config/source/clone.go
Normal file
@@ -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
|
||||
}
|
||||
65
pkg/config/source/config_source.go
Normal file
65
pkg/config/source/config_source.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
// ConfigSource implements Source for in-memory configuration.
|
||||
// All operations are thread-safe.
|
||||
type ConfigSource struct {
|
||||
baseSource
|
||||
}
|
||||
|
||||
func NewConfigSource() *ConfigSource {
|
||||
return &ConfigSource{
|
||||
baseSource: newBaseSource(),
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceAll replaces all proxy and visitor configurations atomically.
|
||||
func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
|
||||
for _, p := range proxies {
|
||||
if p == nil {
|
||||
return fmt.Errorf("proxy cannot be nil")
|
||||
}
|
||||
name := p.GetBaseConfig().Name
|
||||
if name == "" {
|
||||
return fmt.Errorf("proxy name cannot be empty")
|
||||
}
|
||||
nextProxies[name] = p
|
||||
}
|
||||
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
|
||||
for _, v := range visitors {
|
||||
if v == nil {
|
||||
return fmt.Errorf("visitor cannot be nil")
|
||||
}
|
||||
name := v.GetBaseConfig().Name
|
||||
if name == "" {
|
||||
return fmt.Errorf("visitor name cannot be empty")
|
||||
}
|
||||
nextVisitors[name] = v
|
||||
}
|
||||
s.proxies = nextProxies
|
||||
s.visitors = nextVisitors
|
||||
return nil
|
||||
}
|
||||
173
pkg/config/source/config_source_test.go
Normal file
173
pkg/config/source/config_source_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestNewConfigSource(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
src := NewConfigSource()
|
||||
require.NotNil(src)
|
||||
}
|
||||
|
||||
func TestConfigSource_ReplaceAll(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
src := NewConfigSource()
|
||||
|
||||
err := src.ReplaceAll(
|
||||
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
|
||||
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
proxies, visitors, err := src.Load()
|
||||
require.NoError(err)
|
||||
require.Len(proxies, 2)
|
||||
require.Len(visitors, 1)
|
||||
|
||||
// ReplaceAll again should replace everything
|
||||
err = src.ReplaceAll(
|
||||
[]v1.ProxyConfigurer{mockProxy("proxy3")},
|
||||
nil,
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
proxies, visitors, err = src.Load()
|
||||
require.NoError(err)
|
||||
require.Len(proxies, 1)
|
||||
require.Len(visitors, 0)
|
||||
require.Equal("proxy3", proxies[0].GetBaseConfig().Name)
|
||||
|
||||
// ReplaceAll with nil proxy should fail
|
||||
err = src.ReplaceAll([]v1.ProxyConfigurer{nil}, nil)
|
||||
require.Error(err)
|
||||
|
||||
// ReplaceAll with empty name proxy should fail
|
||||
err = src.ReplaceAll([]v1.ProxyConfigurer{&v1.TCPProxyConfig{}}, nil)
|
||||
require.Error(err)
|
||||
}
|
||||
|
||||
func TestConfigSource_Load(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
src := NewConfigSource()
|
||||
|
||||
err := src.ReplaceAll(
|
||||
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
|
||||
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
proxies, visitors, err := src.Load()
|
||||
require.NoError(err)
|
||||
require.Len(proxies, 2)
|
||||
require.Len(visitors, 1)
|
||||
}
|
||||
|
||||
// TestConfigSource_Load_FiltersDisabled verifies that Load() filters out
|
||||
// proxies and visitors with Enabled explicitly set to false.
|
||||
func TestConfigSource_Load_FiltersDisabled(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
src := NewConfigSource()
|
||||
|
||||
disabled := false
|
||||
enabled := true
|
||||
|
||||
// Create enabled proxy (nil Enabled = enabled by default)
|
||||
enabledProxy := mockProxy("enabled-proxy")
|
||||
|
||||
// Create disabled proxy
|
||||
disabledProxy := &v1.TCPProxyConfig{}
|
||||
disabledProxy.Name = "disabled-proxy"
|
||||
disabledProxy.Type = "tcp"
|
||||
disabledProxy.Enabled = &disabled
|
||||
|
||||
// Create explicitly enabled proxy
|
||||
explicitEnabledProxy := &v1.TCPProxyConfig{}
|
||||
explicitEnabledProxy.Name = "explicit-enabled-proxy"
|
||||
explicitEnabledProxy.Type = "tcp"
|
||||
explicitEnabledProxy.Enabled = &enabled
|
||||
|
||||
// Create enabled visitor (nil Enabled = enabled by default)
|
||||
enabledVisitor := mockVisitor("enabled-visitor")
|
||||
|
||||
// Create disabled visitor
|
||||
disabledVisitor := &v1.STCPVisitorConfig{}
|
||||
disabledVisitor.Name = "disabled-visitor"
|
||||
disabledVisitor.Type = "stcp"
|
||||
disabledVisitor.Enabled = &disabled
|
||||
|
||||
err := src.ReplaceAll(
|
||||
[]v1.ProxyConfigurer{enabledProxy, disabledProxy, explicitEnabledProxy},
|
||||
[]v1.VisitorConfigurer{enabledVisitor, disabledVisitor},
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
// Load should filter out disabled configs
|
||||
proxies, visitors, err := src.Load()
|
||||
require.NoError(err)
|
||||
require.Len(proxies, 2, "Should have 2 enabled proxies")
|
||||
require.Len(visitors, 1, "Should have 1 enabled visitor")
|
||||
|
||||
// Verify the correct proxies are returned
|
||||
proxyNames := make([]string, 0, len(proxies))
|
||||
for _, p := range proxies {
|
||||
proxyNames = append(proxyNames, p.GetBaseConfig().Name)
|
||||
}
|
||||
require.Contains(proxyNames, "enabled-proxy")
|
||||
require.Contains(proxyNames, "explicit-enabled-proxy")
|
||||
require.NotContains(proxyNames, "disabled-proxy")
|
||||
|
||||
// Verify the correct visitor is returned
|
||||
require.Equal("enabled-visitor", visitors[0].GetBaseConfig().Name)
|
||||
}
|
||||
|
||||
func TestConfigSource_ReplaceAll_DoesNotApplyRuntimeDefaults(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
src := NewConfigSource()
|
||||
|
||||
proxyCfg := &v1.TCPProxyConfig{}
|
||||
proxyCfg.Name = "proxy1"
|
||||
proxyCfg.Type = "tcp"
|
||||
proxyCfg.LocalPort = 10080
|
||||
|
||||
visitorCfg := &v1.XTCPVisitorConfig{}
|
||||
visitorCfg.Name = "visitor1"
|
||||
visitorCfg.Type = "xtcp"
|
||||
visitorCfg.ServerName = "server1"
|
||||
visitorCfg.SecretKey = "secret"
|
||||
visitorCfg.BindPort = 10081
|
||||
|
||||
err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
|
||||
require.NoError(err)
|
||||
|
||||
proxies, visitors, err := src.Load()
|
||||
require.NoError(err)
|
||||
require.Len(proxies, 1)
|
||||
require.Len(visitors, 1)
|
||||
require.Empty(proxies[0].GetBaseConfig().LocalIP)
|
||||
require.Empty(visitors[0].GetBaseConfig().BindAddr)
|
||||
require.Empty(visitors[0].(*v1.XTCPVisitorConfig).Protocol)
|
||||
}
|
||||
37
pkg/config/source/source.go
Normal file
37
pkg/config/source/source.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
// Source is the interface for configuration sources.
|
||||
// A Source provides proxy and visitor configurations from various backends.
|
||||
// Aggregator currently uses the built-in config source as base and an optional
|
||||
// store source as higher-priority overlay.
|
||||
type Source interface {
|
||||
// Load loads the proxy and visitor configurations from this source.
|
||||
// Returns the loaded configurations and any error encountered.
|
||||
// A disabled entry in one source is source-local filtering, not a cross-source
|
||||
// tombstone for entries from lower-priority sources.
|
||||
//
|
||||
// Error handling contract with Aggregator:
|
||||
// - When err is nil, returned slices are consumed.
|
||||
// - When err is non-nil, Aggregator aborts the merge and returns the error.
|
||||
// - To publish best-effort or partial results, return those results with
|
||||
// err set to nil.
|
||||
Load() (proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, err error)
|
||||
}
|
||||
367
pkg/config/source/store.go
Normal file
367
pkg/config/source/store.go
Normal file
@@ -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
|
||||
}
|
||||
121
pkg/config/source/store_test.go
Normal file
121
pkg/config/source/store_test.go
Normal file
@@ -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"))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
109
pkg/config/v1/clone_test.go
Normal file
109
pkg/config/v1/clone_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
195
pkg/config/v1/decode.go
Normal file
195
pkg/config/v1/decode.go
Normal file
@@ -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
|
||||
}
|
||||
86
pkg/config/v1/decode_test.go
Normal file
86
pkg/config/v1/decode_test.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -54,6 +51,7 @@ var clientPluginOptionsTypeMap = map[string]reflect.Type{
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
26
pkg/config/v1/store.go
Normal file
26
pkg/config/v1/store.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 The frp Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package v1
|
||||
|
||||
// StoreConfig configures the built-in store source.
|
||||
type StoreConfig struct {
|
||||
// Path is the store file path.
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// IsEnabled returns true if the store is configured with a valid path.
|
||||
func (c *StoreConfig) IsEnabled() bool {
|
||||
return c.Path != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,11 +15,9 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/fatedier/frp/pkg/util/jsonx"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,6 +30,7 @@ var visitorPluginOptionsTypeMap = map[string]reflect.Type{
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
33
pkg/naming/names.go
Normal file
33
pkg/naming/names.go
Normal file
@@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
||||
prefix := user + "."
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return strings.TrimPrefix(name, prefix)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// BuildTargetServerProxyName resolves visitor target proxy name for wire-level
|
||||
// protocol messages. serverUser overrides local user when set.
|
||||
func BuildTargetServerProxyName(localUser, serverUser, serverName string) string {
|
||||
if serverUser != "" {
|
||||
return AddUserPrefix(serverUser, serverName)
|
||||
}
|
||||
return AddUserPrefix(localUser, serverName)
|
||||
}
|
||||
27
pkg/naming/names_test.go
Normal file
27
pkg/naming/names_test.go
Normal file
@@ -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"))
|
||||
}
|
||||
@@ -375,7 +375,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
|
||||
|
||||
@@ -171,8 +171,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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
45
pkg/util/jsonx/json_v1.go
Normal file
45
pkg/util/jsonx/json_v1.go
Normal file
@@ -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)
|
||||
}
|
||||
36
pkg/util/jsonx/raw_message.go
Normal file
36
pkg/util/jsonx/raw_message.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.67.0"
|
||||
var version = "0.68.0"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
@@ -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,
|
||||
|
||||
64
server/api_router.go
Normal file
64
server/api_router.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -257,29 +229,17 @@ func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
||||
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
|
||||
}
|
||||
71
server/http/controller_test.go
Normal file
71
server/http/controller_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == "":
|
||||
|
||||
@@ -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"
|
||||
@@ -622,7 +620,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
|
||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
remoteAddr = host
|
||||
}
|
||||
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, remoteAddr)
|
||||
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr)
|
||||
if conflict {
|
||||
svr.ctlManager.Del(loginMsg.RunID, ctl)
|
||||
ctl.Close()
|
||||
@@ -690,42 +688,3 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
|
||||
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)
|
||||
}
|
||||
|
||||
323
test/e2e/v1/features/store.go
Normal file
323
test/e2e/v1/features/store.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package features
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
|
||||
"github.com/fatedier/frp/test/e2e/framework"
|
||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("[Feature: Store]", func() {
|
||||
f := framework.NewDefaultFramework()
|
||||
|
||||
ginkgo.Describe("Store API", func() {
|
||||
ginkgo.It("create proxy via API and verify connection", func() {
|
||||
adminPort := f.AllocPort()
|
||||
remotePort := f.AllocPort()
|
||||
|
||||
serverConf := consts.DefaultServerConfig
|
||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||
webServer.addr = "127.0.0.1"
|
||||
webServer.port = %d
|
||||
|
||||
[store]
|
||||
path = "%s/store.json"
|
||||
`, adminPort, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
proxyConfig := map[string]any{
|
||||
"name": "test-tcp",
|
||||
"type": "tcp",
|
||||
"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
|
||||
})
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
})
|
||||
|
||||
ginkgo.It("update proxy via API", func() {
|
||||
adminPort := f.AllocPort()
|
||||
remotePort1 := f.AllocPort()
|
||||
remotePort2 := f.AllocPort()
|
||||
|
||||
serverConf := consts.DefaultServerConfig
|
||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||
webServer.addr = "127.0.0.1"
|
||||
webServer.port = %d
|
||||
|
||||
[store]
|
||||
path = "%s/store.json"
|
||||
`, adminPort, f.TempDirectory)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
proxyConfig := map[string]any{
|
||||
"name": "test-tcp",
|
||||
"type": "tcp",
|
||||
"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
|
||||
})
|
||||
|
||||
time.Sleep(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
|
||||
})
|
||||
|
||||
time.Sleep(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([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
time.Sleep(time.Second)
|
||||
framework.NewRequestExpect(f).Port(remotePort).Ensure()
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp").HTTPParams("DELETE", "", "/api/store/proxies/test-tcp", nil)
|
||||
}).Ensure(func(resp *request.Response) bool {
|
||||
return resp.Code == 200
|
||||
})
|
||||
|
||||
time.Sleep(time.Second)
|
||||
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).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([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
|
||||
}).Ensure(func(resp *request.Response) bool {
|
||||
return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp")
|
||||
})
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/test-tcp")
|
||||
}).Ensure(func(resp *request.Response) bool {
|
||||
return resp.Code == 200 && strings.Contains(string(resp.Content), "test-tcp")
|
||||
})
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies/nonexistent")
|
||||
}).Ensure(func(resp *request.Response) bool {
|
||||
return resp.Code == 404
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.It("store disabled returns 404", func() {
|
||||
adminPort := f.AllocPort()
|
||||
|
||||
serverConf := consts.DefaultServerConfig
|
||||
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||
webServer.addr = "127.0.0.1"
|
||||
webServer.port = %d
|
||||
`, adminPort)
|
||||
|
||||
f.RunProcesses([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
|
||||
r.HTTP().Port(adminPort).HTTPPath("/api/store/proxies")
|
||||
}).Ensure(func(resp *request.Response) bool {
|
||||
return resp.Code == 404
|
||||
})
|
||||
})
|
||||
|
||||
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([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
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([]string{serverConf}, []string{clientConf})
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
10
web/frpc/components.d.ts
vendored
10
web/frpc/components.d.ts
vendored
@@ -10,13 +10,21 @@ declare module 'vue' {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default']
|
||||
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !noweb
|
||||
|
||||
package frpc
|
||||
|
||||
import (
|
||||
|
||||
3
web/frpc/embed_stub.go
Normal file
3
web/frpc/embed_stub.go
Normal file
@@ -0,0 +1,3 @@
|
||||
//go:build noweb
|
||||
|
||||
package frpc
|
||||
36
web/frpc/eslint.config.js
Normal file
36
web/frpc/eslint.config.js
Normal file
@@ -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,
|
||||
]
|
||||
4673
web/frpc/package-lock.json
generated
4673
web/frpc/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,14 @@
|
||||
"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",
|
||||
@@ -16,14 +17,13 @@
|
||||
"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 +37,4 @@
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue-tsc": "^3.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ const isDark = useDark()
|
||||
const currentRouteName = computed(() => {
|
||||
if (route.path === '/') return 'Overview'
|
||||
if (route.path === '/configure') return 'Configure'
|
||||
if (route.path === '/proxies/create') return 'Create Proxy'
|
||||
if (route.path.startsWith('/proxies/') && route.path.endsWith('/edit'))
|
||||
return 'Edit Proxy'
|
||||
if (route.path === '/visitors/create') return 'Create Visitor'
|
||||
if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit'))
|
||||
return 'Edit Visitor'
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { http } from './http'
|
||||
import type { StatusResponse } from '../types/proxy'
|
||||
import type {
|
||||
StatusResponse,
|
||||
ProxyListResp,
|
||||
ProxyDefinition,
|
||||
VisitorListResp,
|
||||
VisitorDefinition,
|
||||
} from '../types/proxy'
|
||||
|
||||
export const getStatus = () => {
|
||||
return http.get<StatusResponse>('/api/status')
|
||||
@@ -16,3 +22,58 @@ export const putConfig = (content: string) => {
|
||||
export const reloadConfig = () => {
|
||||
return http.get<void>('/api/reload')
|
||||
}
|
||||
|
||||
// Store API - Proxies
|
||||
export const listStoreProxies = () => {
|
||||
return http.get<ProxyListResp>('/api/store/proxies')
|
||||
}
|
||||
|
||||
export const getStoreProxy = (name: string) => {
|
||||
return http.get<ProxyDefinition>(
|
||||
`/api/store/proxies/${encodeURIComponent(name)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export const createStoreProxy = (config: ProxyDefinition) => {
|
||||
return http.post<ProxyDefinition>('/api/store/proxies', config)
|
||||
}
|
||||
|
||||
export const updateStoreProxy = (name: string, config: ProxyDefinition) => {
|
||||
return http.put<ProxyDefinition>(
|
||||
`/api/store/proxies/${encodeURIComponent(name)}`,
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
export const deleteStoreProxy = (name: string) => {
|
||||
return http.delete<void>(`/api/store/proxies/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
// Store API - Visitors
|
||||
export const listStoreVisitors = () => {
|
||||
return http.get<VisitorListResp>('/api/store/visitors')
|
||||
}
|
||||
|
||||
export const getStoreVisitor = (name: string) => {
|
||||
return http.get<VisitorDefinition>(
|
||||
`/api/store/visitors/${encodeURIComponent(name)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export const createStoreVisitor = (config: VisitorDefinition) => {
|
||||
return http.post<VisitorDefinition>('/api/store/visitors', config)
|
||||
}
|
||||
|
||||
export const updateStoreVisitor = (
|
||||
name: string,
|
||||
config: VisitorDefinition,
|
||||
) => {
|
||||
return http.put<VisitorDefinition>(
|
||||
`/api/store/visitors/${encodeURIComponent(name)}`,
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
export const deleteStoreVisitor = (name: string) => {
|
||||
return http.delete<void>(`/api/store/visitors/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
153
web/frpc/src/components/KeyValueEditor.vue
Normal file
153
web/frpc/src/components/KeyValueEditor.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="kv-editor">
|
||||
<div v-for="(entry, index) in modelValue" :key="index" class="kv-row">
|
||||
<el-input
|
||||
:model-value="entry.key"
|
||||
:placeholder="keyPlaceholder"
|
||||
class="kv-input"
|
||||
@update:model-value="updateEntry(index, 'key', $event)"
|
||||
/>
|
||||
<el-input
|
||||
:model-value="entry.value"
|
||||
:placeholder="valuePlaceholder"
|
||||
class="kv-input"
|
||||
@update:model-value="updateEntry(index, 'value', $event)"
|
||||
/>
|
||||
<button class="kv-remove-btn" @click="removeEntry(index)">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="kv-add-btn" @click="addEntry">
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface KVEntry {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: KVEntry[]
|
||||
keyPlaceholder?: string
|
||||
valuePlaceholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
keyPlaceholder: 'Key',
|
||||
valuePlaceholder: 'Value',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: KVEntry[]]
|
||||
}>()
|
||||
|
||||
const updateEntry = (index: number, field: 'key' | 'value', val: string) => {
|
||||
const updated = [...props.modelValue]
|
||||
updated[index] = { ...updated[index], [field]: val }
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
const addEntry = () => {
|
||||
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
|
||||
}
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
const updated = props.modelValue.filter((_, i) => i !== index)
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kv-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kv-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.kv-remove-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kv-remove-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.kv-remove-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
html.dark .kv-remove-btn:hover {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.kv-add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.kv-add-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.kv-add-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +1,46 @@
|
||||
<template>
|
||||
<div class="proxy-card" :class="{ 'has-error': proxy.err }">
|
||||
<div
|
||||
class="proxy-card"
|
||||
:class="{ 'has-error': proxy.err, 'is-store': isStore }"
|
||||
>
|
||||
<div class="card-main">
|
||||
<div class="card-left">
|
||||
<div class="card-header">
|
||||
<span class="proxy-name">{{ proxy.name }}</span>
|
||||
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
|
||||
<span class="type-tag" :class="`type-${proxy.type}`">{{
|
||||
proxy.type.toUpperCase()
|
||||
}}</span>
|
||||
<span v-if="isStore" class="source-tag">
|
||||
<svg
|
||||
class="store-icon"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2 4.5A1.5 1.5 0 013.5 3h9A1.5 1.5 0 0114 4.5v1a.5.5 0 01-.5.5h-11a.5.5 0 01-.5-.5v-1z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3 7v5.5A1.5 1.5 0 004.5 14h7a1.5 1.5 0 001.5-1.5V7H3zm4 2h2a.5.5 0 010 1H7a.5.5 0 010-1z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Store
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span v-if="proxy.local_addr" class="meta-item">
|
||||
<span class="meta-label">Local:</span>
|
||||
<span class="meta-label">Local</span>
|
||||
<span class="meta-value code">{{ proxy.local_addr }}</span>
|
||||
</span>
|
||||
<span v-if="proxy.plugin" class="meta-item">
|
||||
<span class="meta-label">Plugin:</span>
|
||||
<span class="meta-label">Plugin</span>
|
||||
<span class="meta-value code">{{ proxy.plugin }}</span>
|
||||
</span>
|
||||
<span v-if="proxy.remote_addr" class="meta-item">
|
||||
<span class="meta-label">Remote:</span>
|
||||
<span class="meta-label">Remote</span>
|
||||
<span class="meta-value code">{{ proxy.remote_addr }}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -25,12 +48,58 @@
|
||||
|
||||
<div class="card-right">
|
||||
<div v-if="proxy.err" class="error-info">
|
||||
<el-icon class="error-icon"><Warning /></el-icon>
|
||||
<span class="error-text">{{ proxy.err }}</span>
|
||||
<el-tooltip :content="proxy.err" placement="top" :show-after="300">
|
||||
<div class="error-badge">
|
||||
<el-icon class="error-icon"><Warning /></el-icon>
|
||||
<span class="error-text">Error</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="status-badge" :class="statusClass">
|
||||
<span class="status-dot"></span>
|
||||
{{ proxy.status }}
|
||||
</div>
|
||||
|
||||
<!-- Store actions -->
|
||||
<div v-if="isStore" class="card-actions">
|
||||
<button
|
||||
class="action-btn edit-btn"
|
||||
@click.stop="$emit('edit', proxy)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.293 1.293a1 1 0 011.414 0l2 2a1 1 0 010 1.414l-9 9A1 1 0 015 14H3a1 1 0 01-1-1v-2a1 1 0 01.293-.707l9-9z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn delete-btn"
|
||||
@click.stop="$emit('delete', proxy)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.5 3a1 1 0 01-1 1H13v9a2 2 0 01-2 2H5a2 2 0 01-2-2V4h-.5a1 1 0 010-2H6a1 1 0 011-1h2a1 1 0 011 1h3.5a1 1 0 011 1zM4.118 4L4 4.059V13a1 1 0 001 1h6a1 1 0 001-1V4.059L11.882 4H4.118zM6 2h4v1H6V2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,6 +116,13 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [proxy: ProxyStatus]
|
||||
delete: [proxy: ProxyStatus]
|
||||
}>()
|
||||
|
||||
const isStore = computed(() => props.proxy.source === 'store')
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.proxy.status) {
|
||||
case 'running':
|
||||
@@ -61,17 +137,20 @@ const statusClass = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.proxy-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.proxy-card:hover {
|
||||
border-color: var(--el-border-color-light);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||
border-color: var(--el-border-color);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.06),
|
||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.proxy-card.has-error {
|
||||
@@ -86,9 +165,9 @@ html.dark .proxy-card.has-error {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
gap: 24px;
|
||||
min-height: 80px;
|
||||
padding: 18px 20px;
|
||||
gap: 20px;
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
/* Left Section */
|
||||
@@ -96,7 +175,7 @@ html.dark .proxy-card.has-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -104,120 +183,303 @@ html.dark .proxy-card.has-error {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.proxy-name {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.4;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.type-tag.type-tcp {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
.type-tag.type-udp {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.type-tag.type-http {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
.type-tag.type-https {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #059669;
|
||||
}
|
||||
.type-tag.type-stcp,
|
||||
.type-tag.type-sudp,
|
||||
.type-tag.type-xtcp {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
.type-tag.type-tcpmux {
|
||||
background: rgba(236, 72, 153, 0.1);
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
html.dark .type-tag.type-tcp {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
html.dark .type-tag.type-udp {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
html.dark .type-tag.type-http {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #34d399;
|
||||
}
|
||||
html.dark .type-tag.type-https {
|
||||
background: rgba(52, 211, 153, 0.2);
|
||||
color: #34d399;
|
||||
}
|
||||
html.dark .type-tag.type-stcp,
|
||||
html.dark .type-tag.type-sudp,
|
||||
html.dark .type-tag.type-xtcp {
|
||||
background: rgba(167, 139, 250, 0.15);
|
||||
color: #a78bfa;
|
||||
}
|
||||
html.dark .type-tag.type-tcpmux {
|
||||
background: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(102, 126, 234, 0.1) 0%,
|
||||
rgba(118, 75, 162, 0.1) 100%
|
||||
);
|
||||
color: #764ba2;
|
||||
}
|
||||
|
||||
html.dark .source-tag {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(129, 140, 248, 0.15) 0%,
|
||||
rgba(167, 139, 250, 0.15) 100%
|
||||
);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.store-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.meta-value.code {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
'SF Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Right Section */
|
||||
.card-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
.error-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 200px;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-danger-light-9);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--el-color-danger);
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--el-color-danger);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 4px 12px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.status-badge.running .status-dot {
|
||||
background: var(--el-color-success);
|
||||
box-shadow: 0 0 0 2px var(--el-color-success-light-7);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.status-badge.error .status-dot {
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.status-badge.waiting {
|
||||
background: var(--el-color-warning-light-9);
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
.status-badge.waiting .status-dot {
|
||||
background: var(--el-color-warning);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.proxy-card.is-store:hover .status-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.proxy-card:hover .card-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
html.dark .edit-btn:hover {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
html.dark .delete-btn:hover {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.card-main {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
@@ -225,12 +487,7 @@ html.dark .proxy-card.has-error {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
padding-top: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Overview from '../views/Overview.vue'
|
||||
import ClientConfigure from '../views/ClientConfigure.vue'
|
||||
import ProxyEdit from '../views/ProxyEdit.vue'
|
||||
import VisitorEdit from '../views/VisitorEdit.vue'
|
||||
import { listStoreProxies } from '../api/frpc'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
@@ -15,7 +19,59 @@ const router = createRouter({
|
||||
name: 'ClientConfigure',
|
||||
component: ClientConfigure,
|
||||
},
|
||||
{
|
||||
path: '/proxies/create',
|
||||
name: 'ProxyCreate',
|
||||
component: ProxyEdit,
|
||||
meta: { requiresStore: true },
|
||||
},
|
||||
{
|
||||
path: '/proxies/:name/edit',
|
||||
name: 'ProxyEdit',
|
||||
component: ProxyEdit,
|
||||
meta: { requiresStore: true },
|
||||
},
|
||||
{
|
||||
path: '/visitors/create',
|
||||
name: 'VisitorCreate',
|
||||
component: VisitorEdit,
|
||||
meta: { requiresStore: true },
|
||||
},
|
||||
{
|
||||
path: '/visitors/:name/edit',
|
||||
name: 'VisitorEdit',
|
||||
component: VisitorEdit,
|
||||
meta: { requiresStore: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const isStoreEnabled = async () => {
|
||||
try {
|
||||
await listStoreProxies()
|
||||
return true
|
||||
} catch (err: any) {
|
||||
if (err?.status === 404) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (!to.matched.some((record) => record.meta.requiresStore)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const enabled = await isStoreEnabled()
|
||||
if (enabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
ElMessage.warning(
|
||||
'Store is disabled. Enable Store in frpc config to create or edit store entries.',
|
||||
)
|
||||
return { name: 'Overview' }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// ========================================
|
||||
// RUNTIME STATUS TYPES (from /api/status)
|
||||
// ========================================
|
||||
|
||||
export interface ProxyStatus {
|
||||
name: string
|
||||
type: string
|
||||
@@ -6,7 +10,719 @@ export interface ProxyStatus {
|
||||
local_addr: string
|
||||
plugin: string
|
||||
remote_addr: string
|
||||
source?: 'store' | 'config'
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type StatusResponse = Record<string, ProxyStatus[]>
|
||||
|
||||
// ========================================
|
||||
// STORE API TYPES
|
||||
// ========================================
|
||||
|
||||
export interface ProxyDefinition {
|
||||
name: string
|
||||
type: ProxyType
|
||||
tcp?: Record<string, any>
|
||||
udp?: Record<string, any>
|
||||
http?: Record<string, any>
|
||||
https?: Record<string, any>
|
||||
tcpmux?: Record<string, any>
|
||||
stcp?: Record<string, any>
|
||||
sudp?: Record<string, any>
|
||||
xtcp?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface VisitorDefinition {
|
||||
name: string
|
||||
type: VisitorType
|
||||
stcp?: Record<string, any>
|
||||
sudp?: Record<string, any>
|
||||
xtcp?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ProxyListResp {
|
||||
proxies: ProxyDefinition[]
|
||||
}
|
||||
|
||||
export interface VisitorListResp {
|
||||
visitors: VisitorDefinition[]
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CONSTANTS
|
||||
// ========================================
|
||||
|
||||
export const PROXY_TYPES = [
|
||||
'tcp',
|
||||
'udp',
|
||||
'http',
|
||||
'https',
|
||||
'stcp',
|
||||
'sudp',
|
||||
'xtcp',
|
||||
'tcpmux',
|
||||
] as const
|
||||
|
||||
export type ProxyType = (typeof PROXY_TYPES)[number]
|
||||
|
||||
export const VISITOR_TYPES = ['stcp', 'sudp', 'xtcp'] as const
|
||||
|
||||
export type VisitorType = (typeof VISITOR_TYPES)[number]
|
||||
|
||||
export const PLUGIN_TYPES = [
|
||||
'',
|
||||
'http2https',
|
||||
'http_proxy',
|
||||
'https2http',
|
||||
'https2https',
|
||||
'http2http',
|
||||
'socks5',
|
||||
'static_file',
|
||||
'unix_domain_socket',
|
||||
'tls2raw',
|
||||
'virtual_net',
|
||||
] as const
|
||||
|
||||
export type PluginType = (typeof PLUGIN_TYPES)[number]
|
||||
|
||||
// ========================================
|
||||
// FORM DATA INTERFACES
|
||||
// ========================================
|
||||
|
||||
export interface ProxyFormData {
|
||||
// Base fields (ProxyBaseConfig)
|
||||
name: string
|
||||
type: ProxyType
|
||||
enabled: boolean
|
||||
|
||||
// Backend (ProxyBackend)
|
||||
localIP: string
|
||||
localPort: number | undefined
|
||||
pluginType: string
|
||||
pluginConfig: Record<string, any>
|
||||
|
||||
// Transport (ProxyTransport)
|
||||
useEncryption: boolean
|
||||
useCompression: boolean
|
||||
bandwidthLimit: string
|
||||
bandwidthLimitMode: string
|
||||
proxyProtocolVersion: string
|
||||
|
||||
// Load Balancer (LoadBalancerConfig)
|
||||
loadBalancerGroup: string
|
||||
loadBalancerGroupKey: string
|
||||
|
||||
// Health Check (HealthCheckConfig)
|
||||
healthCheckType: string
|
||||
healthCheckTimeoutSeconds: number | undefined
|
||||
healthCheckMaxFailed: number | undefined
|
||||
healthCheckIntervalSeconds: number | undefined
|
||||
healthCheckPath: string
|
||||
healthCheckHTTPHeaders: Array<{ name: string; value: string }>
|
||||
|
||||
// Metadata & Annotations
|
||||
metadatas: Array<{ key: string; value: string }>
|
||||
annotations: Array<{ key: string; value: string }>
|
||||
|
||||
// TCP/UDP specific
|
||||
remotePort: number | undefined
|
||||
|
||||
// Domain (HTTP/HTTPS/TCPMux) - DomainConfig
|
||||
customDomains: string
|
||||
subdomain: string
|
||||
|
||||
// HTTP specific (HTTPProxyConfig)
|
||||
locations: string
|
||||
httpUser: string
|
||||
httpPassword: string
|
||||
hostHeaderRewrite: string
|
||||
requestHeaders: Array<{ key: string; value: string }>
|
||||
responseHeaders: Array<{ key: string; value: string }>
|
||||
routeByHTTPUser: string
|
||||
|
||||
// TCPMux specific
|
||||
multiplexer: string
|
||||
|
||||
// STCP/SUDP/XTCP specific
|
||||
secretKey: string
|
||||
allowUsers: string
|
||||
|
||||
// XTCP specific (NatTraversalConfig)
|
||||
natTraversalDisableAssistedAddrs: boolean
|
||||
}
|
||||
|
||||
export interface VisitorFormData {
|
||||
// Base fields (VisitorBaseConfig)
|
||||
name: string
|
||||
type: VisitorType
|
||||
enabled: boolean
|
||||
|
||||
// Transport (VisitorTransport)
|
||||
useEncryption: boolean
|
||||
useCompression: boolean
|
||||
|
||||
// Connection
|
||||
secretKey: string
|
||||
serverUser: string
|
||||
serverName: string
|
||||
bindAddr: string
|
||||
bindPort: number | undefined
|
||||
|
||||
// XTCP specific (XTCPVisitorConfig)
|
||||
protocol: string
|
||||
keepTunnelOpen: boolean
|
||||
maxRetriesAnHour: number | undefined
|
||||
minRetryInterval: number | undefined
|
||||
fallbackTo: string
|
||||
fallbackTimeoutMs: number | undefined
|
||||
natTraversalDisableAssistedAddrs: boolean
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// DEFAULT FORM CREATORS
|
||||
// ========================================
|
||||
|
||||
export function createDefaultProxyForm(): ProxyFormData {
|
||||
return {
|
||||
name: '',
|
||||
type: 'tcp',
|
||||
enabled: true,
|
||||
|
||||
localIP: '127.0.0.1',
|
||||
localPort: undefined,
|
||||
pluginType: '',
|
||||
pluginConfig: {},
|
||||
|
||||
useEncryption: false,
|
||||
useCompression: false,
|
||||
bandwidthLimit: '',
|
||||
bandwidthLimitMode: 'client',
|
||||
proxyProtocolVersion: '',
|
||||
|
||||
loadBalancerGroup: '',
|
||||
loadBalancerGroupKey: '',
|
||||
|
||||
healthCheckType: '',
|
||||
healthCheckTimeoutSeconds: undefined,
|
||||
healthCheckMaxFailed: undefined,
|
||||
healthCheckIntervalSeconds: undefined,
|
||||
healthCheckPath: '',
|
||||
healthCheckHTTPHeaders: [],
|
||||
|
||||
metadatas: [],
|
||||
annotations: [],
|
||||
|
||||
remotePort: undefined,
|
||||
|
||||
customDomains: '',
|
||||
subdomain: '',
|
||||
|
||||
locations: '',
|
||||
httpUser: '',
|
||||
httpPassword: '',
|
||||
hostHeaderRewrite: '',
|
||||
requestHeaders: [],
|
||||
responseHeaders: [],
|
||||
routeByHTTPUser: '',
|
||||
|
||||
multiplexer: 'httpconnect',
|
||||
|
||||
secretKey: '',
|
||||
allowUsers: '',
|
||||
|
||||
natTraversalDisableAssistedAddrs: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultVisitorForm(): VisitorFormData {
|
||||
return {
|
||||
name: '',
|
||||
type: 'stcp',
|
||||
enabled: true,
|
||||
|
||||
useEncryption: false,
|
||||
useCompression: false,
|
||||
|
||||
secretKey: '',
|
||||
serverUser: '',
|
||||
serverName: '',
|
||||
bindAddr: '127.0.0.1',
|
||||
bindPort: undefined,
|
||||
|
||||
protocol: 'quic',
|
||||
keepTunnelOpen: false,
|
||||
maxRetriesAnHour: undefined,
|
||||
minRetryInterval: undefined,
|
||||
fallbackTo: '',
|
||||
fallbackTimeoutMs: undefined,
|
||||
natTraversalDisableAssistedAddrs: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CONVERTERS: Form -> Store API
|
||||
// ========================================
|
||||
|
||||
export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
|
||||
const block: Record<string, any> = {}
|
||||
|
||||
// Enabled (nil/true = enabled, false = disabled)
|
||||
if (!form.enabled) {
|
||||
block.enabled = false
|
||||
}
|
||||
|
||||
// Backend - LocalIP/LocalPort
|
||||
if (form.pluginType === '') {
|
||||
if (form.localIP && form.localIP !== '127.0.0.1') {
|
||||
block.localIP = form.localIP
|
||||
}
|
||||
if (form.localPort != null) {
|
||||
block.localPort = form.localPort
|
||||
}
|
||||
} else {
|
||||
block.plugin = {
|
||||
type: form.pluginType,
|
||||
...form.pluginConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// Transport
|
||||
if (
|
||||
form.useEncryption ||
|
||||
form.useCompression ||
|
||||
form.bandwidthLimit ||
|
||||
(form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') ||
|
||||
form.proxyProtocolVersion
|
||||
) {
|
||||
block.transport = {}
|
||||
if (form.useEncryption) block.transport.useEncryption = true
|
||||
if (form.useCompression) block.transport.useCompression = true
|
||||
if (form.bandwidthLimit) block.transport.bandwidthLimit = form.bandwidthLimit
|
||||
if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') {
|
||||
block.transport.bandwidthLimitMode = form.bandwidthLimitMode
|
||||
}
|
||||
if (form.proxyProtocolVersion) {
|
||||
block.transport.proxyProtocolVersion = form.proxyProtocolVersion
|
||||
}
|
||||
}
|
||||
|
||||
// Load Balancer
|
||||
if (form.loadBalancerGroup) {
|
||||
block.loadBalancer = {
|
||||
group: form.loadBalancerGroup,
|
||||
}
|
||||
if (form.loadBalancerGroupKey) {
|
||||
block.loadBalancer.groupKey = form.loadBalancerGroupKey
|
||||
}
|
||||
}
|
||||
|
||||
// Health Check
|
||||
if (form.healthCheckType) {
|
||||
block.healthCheck = {
|
||||
type: form.healthCheckType,
|
||||
}
|
||||
if (form.healthCheckTimeoutSeconds != null) {
|
||||
block.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds
|
||||
}
|
||||
if (form.healthCheckMaxFailed != null) {
|
||||
block.healthCheck.maxFailed = form.healthCheckMaxFailed
|
||||
}
|
||||
if (form.healthCheckIntervalSeconds != null) {
|
||||
block.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds
|
||||
}
|
||||
if (form.healthCheckPath) {
|
||||
block.healthCheck.path = form.healthCheckPath
|
||||
}
|
||||
if (form.healthCheckHTTPHeaders.length > 0) {
|
||||
block.healthCheck.httpHeaders = form.healthCheckHTTPHeaders
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
if (form.metadatas.length > 0) {
|
||||
block.metadatas = Object.fromEntries(
|
||||
form.metadatas.map((m) => [m.key, m.value]),
|
||||
)
|
||||
}
|
||||
|
||||
// Annotations
|
||||
if (form.annotations.length > 0) {
|
||||
block.annotations = Object.fromEntries(
|
||||
form.annotations.map((a) => [a.key, a.value]),
|
||||
)
|
||||
}
|
||||
|
||||
// Type-specific fields
|
||||
if ((form.type === 'tcp' || form.type === 'udp') && form.remotePort != null) {
|
||||
block.remotePort = form.remotePort
|
||||
}
|
||||
|
||||
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
|
||||
if (form.customDomains) {
|
||||
block.customDomains = form.customDomains
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (form.subdomain) {
|
||||
block.subdomain = form.subdomain
|
||||
}
|
||||
}
|
||||
|
||||
if (form.type === 'http') {
|
||||
if (form.locations) {
|
||||
block.locations = form.locations
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (form.httpUser) block.httpUser = form.httpUser
|
||||
if (form.httpPassword) block.httpPassword = form.httpPassword
|
||||
if (form.hostHeaderRewrite) block.hostHeaderRewrite = form.hostHeaderRewrite
|
||||
if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser
|
||||
|
||||
if (form.requestHeaders.length > 0) {
|
||||
block.requestHeaders = {
|
||||
set: Object.fromEntries(
|
||||
form.requestHeaders.map((h) => [h.key, h.value]),
|
||||
),
|
||||
}
|
||||
}
|
||||
if (form.responseHeaders.length > 0) {
|
||||
block.responseHeaders = {
|
||||
set: Object.fromEntries(
|
||||
form.responseHeaders.map((h) => [h.key, h.value]),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (form.type === 'tcpmux') {
|
||||
if (form.httpUser) block.httpUser = form.httpUser
|
||||
if (form.httpPassword) block.httpPassword = form.httpPassword
|
||||
if (form.routeByHTTPUser) block.routeByHTTPUser = form.routeByHTTPUser
|
||||
if (form.multiplexer && form.multiplexer !== 'httpconnect') {
|
||||
block.multiplexer = form.multiplexer
|
||||
}
|
||||
}
|
||||
|
||||
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
|
||||
if (form.secretKey) block.secretKey = form.secretKey
|
||||
if (form.allowUsers) {
|
||||
block.allowUsers = form.allowUsers
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
if (form.type === 'xtcp' && form.natTraversalDisableAssistedAddrs) {
|
||||
block.natTraversal = {
|
||||
disableAssistedAddrs: true,
|
||||
}
|
||||
}
|
||||
|
||||
return withStoreProxyBlock(
|
||||
{
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
},
|
||||
form.type,
|
||||
block,
|
||||
)
|
||||
}
|
||||
|
||||
export function formToStoreVisitor(form: VisitorFormData): VisitorDefinition {
|
||||
const block: Record<string, any> = {}
|
||||
|
||||
if (!form.enabled) {
|
||||
block.enabled = false
|
||||
}
|
||||
|
||||
if (form.useEncryption || form.useCompression) {
|
||||
block.transport = {}
|
||||
if (form.useEncryption) block.transport.useEncryption = true
|
||||
if (form.useCompression) block.transport.useCompression = true
|
||||
}
|
||||
|
||||
if (form.secretKey) block.secretKey = form.secretKey
|
||||
if (form.serverUser) block.serverUser = form.serverUser
|
||||
if (form.serverName) block.serverName = form.serverName
|
||||
if (form.bindAddr && form.bindAddr !== '127.0.0.1') {
|
||||
block.bindAddr = form.bindAddr
|
||||
}
|
||||
if (form.bindPort != null) {
|
||||
block.bindPort = form.bindPort
|
||||
}
|
||||
|
||||
if (form.type === 'xtcp') {
|
||||
if (form.protocol && form.protocol !== 'quic') {
|
||||
block.protocol = form.protocol
|
||||
}
|
||||
if (form.keepTunnelOpen) {
|
||||
block.keepTunnelOpen = true
|
||||
}
|
||||
if (form.maxRetriesAnHour != null) {
|
||||
block.maxRetriesAnHour = form.maxRetriesAnHour
|
||||
}
|
||||
if (form.minRetryInterval != null) {
|
||||
block.minRetryInterval = form.minRetryInterval
|
||||
}
|
||||
if (form.fallbackTo) {
|
||||
block.fallbackTo = form.fallbackTo
|
||||
}
|
||||
if (form.fallbackTimeoutMs != null) {
|
||||
block.fallbackTimeoutMs = form.fallbackTimeoutMs
|
||||
}
|
||||
if (form.natTraversalDisableAssistedAddrs) {
|
||||
block.natTraversal = {
|
||||
disableAssistedAddrs: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return withStoreVisitorBlock(
|
||||
{
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
},
|
||||
form.type,
|
||||
block,
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CONVERTERS: Store API -> Form
|
||||
// ========================================
|
||||
|
||||
function getStoreProxyBlock(config: ProxyDefinition): Record<string, any> {
|
||||
switch (config.type) {
|
||||
case 'tcp':
|
||||
return config.tcp || {}
|
||||
case 'udp':
|
||||
return config.udp || {}
|
||||
case 'http':
|
||||
return config.http || {}
|
||||
case 'https':
|
||||
return config.https || {}
|
||||
case 'tcpmux':
|
||||
return config.tcpmux || {}
|
||||
case 'stcp':
|
||||
return config.stcp || {}
|
||||
case 'sudp':
|
||||
return config.sudp || {}
|
||||
case 'xtcp':
|
||||
return config.xtcp || {}
|
||||
}
|
||||
}
|
||||
|
||||
function withStoreProxyBlock(
|
||||
payload: ProxyDefinition,
|
||||
type: ProxyType,
|
||||
block: Record<string, any>,
|
||||
): ProxyDefinition {
|
||||
switch (type) {
|
||||
case 'tcp':
|
||||
payload.tcp = block
|
||||
break
|
||||
case 'udp':
|
||||
payload.udp = block
|
||||
break
|
||||
case 'http':
|
||||
payload.http = block
|
||||
break
|
||||
case 'https':
|
||||
payload.https = block
|
||||
break
|
||||
case 'tcpmux':
|
||||
payload.tcpmux = block
|
||||
break
|
||||
case 'stcp':
|
||||
payload.stcp = block
|
||||
break
|
||||
case 'sudp':
|
||||
payload.sudp = block
|
||||
break
|
||||
case 'xtcp':
|
||||
payload.xtcp = block
|
||||
break
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function getStoreVisitorBlock(config: VisitorDefinition): Record<string, any> {
|
||||
switch (config.type) {
|
||||
case 'stcp':
|
||||
return config.stcp || {}
|
||||
case 'sudp':
|
||||
return config.sudp || {}
|
||||
case 'xtcp':
|
||||
return config.xtcp || {}
|
||||
}
|
||||
}
|
||||
|
||||
function withStoreVisitorBlock(
|
||||
payload: VisitorDefinition,
|
||||
type: VisitorType,
|
||||
block: Record<string, any>,
|
||||
): VisitorDefinition {
|
||||
switch (type) {
|
||||
case 'stcp':
|
||||
payload.stcp = block
|
||||
break
|
||||
case 'sudp':
|
||||
payload.sudp = block
|
||||
break
|
||||
case 'xtcp':
|
||||
payload.xtcp = block
|
||||
break
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export function storeProxyToForm(config: ProxyDefinition): ProxyFormData {
|
||||
const c = getStoreProxyBlock(config)
|
||||
const form = createDefaultProxyForm()
|
||||
|
||||
form.name = config.name || ''
|
||||
form.type = config.type || 'tcp'
|
||||
form.enabled = c.enabled !== false
|
||||
|
||||
// Backend
|
||||
form.localIP = c.localIP || '127.0.0.1'
|
||||
form.localPort = c.localPort
|
||||
if (c.plugin?.type) {
|
||||
form.pluginType = c.plugin.type
|
||||
form.pluginConfig = { ...c.plugin }
|
||||
delete form.pluginConfig.type
|
||||
}
|
||||
|
||||
// Transport
|
||||
if (c.transport) {
|
||||
form.useEncryption = c.transport.useEncryption || false
|
||||
form.useCompression = c.transport.useCompression || false
|
||||
form.bandwidthLimit = c.transport.bandwidthLimit || ''
|
||||
form.bandwidthLimitMode = c.transport.bandwidthLimitMode || 'client'
|
||||
form.proxyProtocolVersion = c.transport.proxyProtocolVersion || ''
|
||||
}
|
||||
|
||||
// Load Balancer
|
||||
if (c.loadBalancer) {
|
||||
form.loadBalancerGroup = c.loadBalancer.group || ''
|
||||
form.loadBalancerGroupKey = c.loadBalancer.groupKey || ''
|
||||
}
|
||||
|
||||
// Health Check
|
||||
if (c.healthCheck) {
|
||||
form.healthCheckType = c.healthCheck.type || ''
|
||||
form.healthCheckTimeoutSeconds = c.healthCheck.timeoutSeconds
|
||||
form.healthCheckMaxFailed = c.healthCheck.maxFailed
|
||||
form.healthCheckIntervalSeconds = c.healthCheck.intervalSeconds
|
||||
form.healthCheckPath = c.healthCheck.path || ''
|
||||
form.healthCheckHTTPHeaders = c.healthCheck.httpHeaders || []
|
||||
}
|
||||
|
||||
// Metadata
|
||||
if (c.metadatas) {
|
||||
form.metadatas = Object.entries(c.metadatas).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}))
|
||||
}
|
||||
|
||||
// Annotations
|
||||
if (c.annotations) {
|
||||
form.annotations = Object.entries(c.annotations).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}))
|
||||
}
|
||||
|
||||
// Type-specific fields
|
||||
form.remotePort = c.remotePort
|
||||
|
||||
// Domain config
|
||||
if (Array.isArray(c.customDomains)) {
|
||||
form.customDomains = c.customDomains.join(', ')
|
||||
} else if (c.customDomains) {
|
||||
form.customDomains = c.customDomains
|
||||
}
|
||||
form.subdomain = c.subdomain || ''
|
||||
|
||||
// HTTP specific
|
||||
if (Array.isArray(c.locations)) {
|
||||
form.locations = c.locations.join(', ')
|
||||
} else if (c.locations) {
|
||||
form.locations = c.locations
|
||||
}
|
||||
form.httpUser = c.httpUser || ''
|
||||
form.httpPassword = c.httpPassword || ''
|
||||
form.hostHeaderRewrite = c.hostHeaderRewrite || ''
|
||||
form.routeByHTTPUser = c.routeByHTTPUser || ''
|
||||
|
||||
// Header operations
|
||||
if (c.requestHeaders?.set) {
|
||||
form.requestHeaders = Object.entries(c.requestHeaders.set).map(
|
||||
([key, value]) => ({ key, value: String(value) }),
|
||||
)
|
||||
}
|
||||
if (c.responseHeaders?.set) {
|
||||
form.responseHeaders = Object.entries(c.responseHeaders.set).map(
|
||||
([key, value]) => ({ key, value: String(value) }),
|
||||
)
|
||||
}
|
||||
|
||||
// TCPMux
|
||||
form.multiplexer = c.multiplexer || 'httpconnect'
|
||||
|
||||
// Secure types
|
||||
form.secretKey = c.secretKey || ''
|
||||
if (Array.isArray(c.allowUsers)) {
|
||||
form.allowUsers = c.allowUsers.join(', ')
|
||||
} else if (c.allowUsers) {
|
||||
form.allowUsers = c.allowUsers
|
||||
}
|
||||
|
||||
// XTCP NAT traversal
|
||||
form.natTraversalDisableAssistedAddrs =
|
||||
c.natTraversal?.disableAssistedAddrs || false
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
export function storeVisitorToForm(
|
||||
config: VisitorDefinition,
|
||||
): VisitorFormData {
|
||||
const c = getStoreVisitorBlock(config)
|
||||
const form = createDefaultVisitorForm()
|
||||
|
||||
form.name = config.name || ''
|
||||
form.type = config.type || 'stcp'
|
||||
form.enabled = c.enabled !== false
|
||||
|
||||
// Transport
|
||||
if (c.transport) {
|
||||
form.useEncryption = c.transport.useEncryption || false
|
||||
form.useCompression = c.transport.useCompression || false
|
||||
}
|
||||
|
||||
// Base fields
|
||||
form.secretKey = c.secretKey || ''
|
||||
form.serverUser = c.serverUser || ''
|
||||
form.serverName = c.serverName || ''
|
||||
form.bindAddr = c.bindAddr || '127.0.0.1'
|
||||
form.bindPort = c.bindPort
|
||||
|
||||
// XTCP specific
|
||||
form.protocol = c.protocol || 'quic'
|
||||
form.keepTunnelOpen = c.keepTunnelOpen || false
|
||||
form.maxRetriesAnHour = c.maxRetriesAnHour
|
||||
form.minRetryInterval = c.minRetryInterval
|
||||
form.fallbackTo = c.fallbackTo || ''
|
||||
form.fallbackTimeoutMs = c.fallbackTimeoutMs
|
||||
form.natTraversalDisableAssistedAddrs =
|
||||
c.natTraversal?.disableAssistedAddrs || false
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
@@ -48,6 +48,28 @@
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select
|
||||
v-model="filterSource"
|
||||
placeholder="Source"
|
||||
clearable
|
||||
class="filter-select"
|
||||
>
|
||||
<el-option label="Config" value="config" />
|
||||
<el-option label="Store" value="store" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filterType"
|
||||
placeholder="Type"
|
||||
clearable
|
||||
class="filter-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="type in availableTypes"
|
||||
:key="type"
|
||||
:label="type.toUpperCase()"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="Search..."
|
||||
@@ -58,6 +80,18 @@
|
||||
<el-tooltip content="Refresh" placement="top">
|
||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="storeEnabled"
|
||||
content="Add new proxy"
|
||||
placement="top"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
circle
|
||||
@click="handleCreate"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -68,10 +102,46 @@
|
||||
v-for="proxy in filteredStatus"
|
||||
:key="proxy.name"
|
||||
:proxy="proxy"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<el-empty description="No proxies found" />
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon">
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="8"
|
||||
y="16"
|
||||
width="48"
|
||||
height="32"
|
||||
rx="4"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="20" cy="32" r="4" fill="currentColor" />
|
||||
<circle cx="32" cy="32" r="4" fill="currentColor" />
|
||||
<circle cx="44" cy="32" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-text">No proxies configured</p>
|
||||
<p class="empty-hint">
|
||||
Add proxies in your configuration file or use Store to create
|
||||
dynamic proxies
|
||||
</p>
|
||||
<el-button
|
||||
v-if="storeEnabled"
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="handleCreate"
|
||||
>
|
||||
Create First Proxy
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -90,7 +160,9 @@
|
||||
v-for="(count, type) in proxyTypeCounts"
|
||||
:key="type"
|
||||
class="proxy-type-item"
|
||||
:class="{ active: filterType === type }"
|
||||
v-show="count > 0"
|
||||
@click="toggleTypeFilter(String(type))"
|
||||
>
|
||||
<div class="proxy-type-name">
|
||||
{{ String(type).toUpperCase() }}
|
||||
@@ -125,6 +197,179 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Store Status Card -->
|
||||
<el-card class="store-status-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">Store</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="storeEnabled ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
>
|
||||
{{ storeEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="store-info">
|
||||
<template v-if="storeEnabled">
|
||||
<div class="store-stat">
|
||||
<span class="store-stat-label">Store Proxies</span>
|
||||
<span class="store-stat-value">{{ storeProxies.length }}</span>
|
||||
</div>
|
||||
<div class="store-stat">
|
||||
<span class="store-stat-label">Store Visitors</span>
|
||||
<span class="store-stat-value">{{ storeVisitors.length }}</span>
|
||||
</div>
|
||||
<p class="store-hint">
|
||||
Proxies from Store are marked with a purple indicator
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="store-disabled-text">
|
||||
Enable Store in your configuration to dynamically manage proxies
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Disabled Store Proxies Section -->
|
||||
<el-row v-if="storeEnabled && disabledStoreProxies.length > 0" :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-card class="disabled-proxies-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">Disabled Store Proxies</span>
|
||||
<el-tag size="small" type="warning">
|
||||
{{ disabledStoreProxies.length }} disabled
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="disabled-proxy-list">
|
||||
<div
|
||||
v-for="proxy in disabledStoreProxies"
|
||||
:key="proxy.name"
|
||||
class="disabled-proxy-card"
|
||||
>
|
||||
<div class="disabled-proxy-info">
|
||||
<span class="disabled-proxy-name">{{ proxy.name }}</span>
|
||||
<el-tag size="small" type="info">{{
|
||||
proxy.type.toUpperCase()
|
||||
}}</el-tag>
|
||||
<el-tag size="small" type="warning" effect="plain"
|
||||
>Disabled</el-tag
|
||||
>
|
||||
</div>
|
||||
<div class="disabled-proxy-actions">
|
||||
<el-button size="small" @click="handleEditStoreProxy(proxy)">
|
||||
Edit
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeleteStoreProxy(proxy)"
|
||||
>
|
||||
Delete
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="disabled-proxy-hint">
|
||||
Edit a proxy and enable it to make it active again.
|
||||
</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Store Visitors Section -->
|
||||
<el-row v-if="storeEnabled" :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-card class="visitors-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">Store Visitors</span>
|
||||
<el-tag size="small" type="info"
|
||||
>{{ storeVisitors.length }} visitors</el-tag
|
||||
>
|
||||
</div>
|
||||
<el-tooltip content="Add new visitor" placement="top">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
circle
|
||||
@click="handleCreateVisitor"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="storeVisitors.length > 0" class="visitor-list">
|
||||
<div
|
||||
v-for="visitor in storeVisitors"
|
||||
:key="visitor.name"
|
||||
class="visitor-card"
|
||||
>
|
||||
<div class="visitor-card-header">
|
||||
<div class="visitor-info">
|
||||
<span class="visitor-name">{{ visitor.name }}</span>
|
||||
<el-tag size="small" type="info">{{
|
||||
visitor.type.toUpperCase()
|
||||
}}</el-tag>
|
||||
</div>
|
||||
<div class="visitor-actions">
|
||||
<el-button size="small" @click="handleEditVisitor(visitor)">
|
||||
Edit
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeleteVisitor(visitor.name)"
|
||||
>
|
||||
Delete
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visitor-card-body">
|
||||
<span v-if="getVisitorBlock(visitor)?.serverName">
|
||||
Server: {{ getVisitorBlock(visitor)?.serverName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
getVisitorBlock(visitor)?.bindAddr ||
|
||||
getVisitorBlock(visitor)?.bindPort != null
|
||||
"
|
||||
>
|
||||
Bind: {{ getVisitorBlock(visitor)?.bindAddr || '127.0.0.1'
|
||||
}}<template v-if="getVisitorBlock(visitor)?.bindPort != null"
|
||||
>:{{ getVisitorBlock(visitor)?.bindPort }}</template
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-content">
|
||||
<p class="empty-text">No visitors configured</p>
|
||||
<p class="empty-hint">
|
||||
Create your first visitor to connect to secure proxies.
|
||||
</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="handleCreateVisitor"
|
||||
>
|
||||
Create First Visitor
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -132,17 +377,37 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getStatus } from '../api/frpc'
|
||||
import type { ProxyStatus } from '../types/proxy'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getStatus,
|
||||
listStoreProxies,
|
||||
deleteStoreProxy,
|
||||
listStoreVisitors,
|
||||
deleteStoreVisitor,
|
||||
} from '../api/frpc'
|
||||
import type {
|
||||
ProxyStatus,
|
||||
ProxyDefinition,
|
||||
VisitorDefinition,
|
||||
} from '../types/proxy'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const status = ref<ProxyStatus[]>([])
|
||||
const storeProxies = ref<ProxyDefinition[]>([])
|
||||
const storeVisitors = ref<VisitorDefinition[]>([])
|
||||
const storeEnabled = ref(false)
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const filterSource = ref('')
|
||||
const filterType = ref('')
|
||||
|
||||
// Computed
|
||||
const stats = computed(() => {
|
||||
const total = status.value.length
|
||||
const running = status.value.filter((p) => p.status === 'running').length
|
||||
@@ -163,41 +428,213 @@ const hasActiveProxies = computed(() => {
|
||||
return status.value.length > 0
|
||||
})
|
||||
|
||||
const filteredStatus = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return status.value
|
||||
}
|
||||
const search = searchText.value.toLowerCase()
|
||||
return status.value.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.type.toLowerCase().includes(search) ||
|
||||
p.local_addr.toLowerCase().includes(search) ||
|
||||
p.remote_addr.toLowerCase().includes(search),
|
||||
)
|
||||
const availableTypes = computed(() => {
|
||||
const types = new Set<string>()
|
||||
status.value.forEach((p) => types.add(p.type))
|
||||
return Array.from(types).sort()
|
||||
})
|
||||
|
||||
const filteredStatus = computed(() => {
|
||||
let result = status.value
|
||||
|
||||
if (filterSource.value) {
|
||||
if (filterSource.value === 'store') {
|
||||
result = result.filter((p) => p.source === 'store')
|
||||
} else {
|
||||
result = result.filter((p) => !p.source || p.source !== 'store')
|
||||
}
|
||||
}
|
||||
|
||||
if (filterType.value) {
|
||||
result = result.filter((p) => p.type === filterType.value)
|
||||
}
|
||||
|
||||
if (searchText.value) {
|
||||
const search = searchText.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.type.toLowerCase().includes(search) ||
|
||||
p.local_addr.toLowerCase().includes(search) ||
|
||||
p.remote_addr.toLowerCase().includes(search),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const disabledStoreProxies = computed(() => {
|
||||
return storeProxies.value.filter((p) => getProxyBlock(p)?.enabled === false)
|
||||
})
|
||||
|
||||
const getProxyBlock = (proxy: ProxyDefinition) => {
|
||||
switch (proxy.type) {
|
||||
case 'tcp':
|
||||
return proxy.tcp
|
||||
case 'udp':
|
||||
return proxy.udp
|
||||
case 'http':
|
||||
return proxy.http
|
||||
case 'https':
|
||||
return proxy.https
|
||||
case 'tcpmux':
|
||||
return proxy.tcpmux
|
||||
case 'stcp':
|
||||
return proxy.stcp
|
||||
case 'sudp':
|
||||
return proxy.sudp
|
||||
case 'xtcp':
|
||||
return proxy.xtcp
|
||||
}
|
||||
}
|
||||
|
||||
const getVisitorBlock = (visitor: VisitorDefinition) => {
|
||||
switch (visitor.type) {
|
||||
case 'stcp':
|
||||
return visitor.stcp
|
||||
case 'sudp':
|
||||
return visitor.sudp
|
||||
case 'xtcp':
|
||||
return visitor.xtcp
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
const toggleTypeFilter = (type: string) => {
|
||||
filterType.value = filterType.value === type ? '' : type
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const json = await getStatus()
|
||||
const list: ProxyStatus[] = []
|
||||
for (const key in json) {
|
||||
for (const ps of json[key]) {
|
||||
list.push(ps)
|
||||
}
|
||||
}
|
||||
status.value = list
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Failed to get status: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStoreProxies = async () => {
|
||||
try {
|
||||
const res = await listStoreProxies()
|
||||
storeProxies.value = res.proxies || []
|
||||
storeEnabled.value = true
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) {
|
||||
storeEnabled.value = false
|
||||
storeProxies.value = []
|
||||
} else {
|
||||
console.error('Failed to fetch store proxies:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStoreVisitors = async () => {
|
||||
try {
|
||||
const res = await listStoreVisitors()
|
||||
storeVisitors.value = res.visitors || []
|
||||
} catch (err: any) {
|
||||
if (err.status === 404) {
|
||||
storeVisitors.value = []
|
||||
} else {
|
||||
console.error('Failed to fetch store visitors:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const json = await getStatus()
|
||||
status.value = []
|
||||
for (const key in json) {
|
||||
for (const ps of json[key]) {
|
||||
status.value.push(ps)
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage({
|
||||
showClose: true,
|
||||
message: 'Get status info from frpc failed! ' + err.message,
|
||||
type: 'warning',
|
||||
})
|
||||
await Promise.all([
|
||||
fetchStoreProxies(),
|
||||
fetchStoreVisitors(),
|
||||
fetchStatus(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push('/proxies/create')
|
||||
}
|
||||
|
||||
const handleEdit = (proxy: ProxyStatus) => {
|
||||
if (proxy.source !== 'store') return
|
||||
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
|
||||
}
|
||||
|
||||
const confirmAndDeleteProxy = async (name: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Are you sure you want to delete "${name}"? This action cannot be undone.`,
|
||||
'Delete Proxy',
|
||||
{
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
},
|
||||
)
|
||||
await deleteStoreProxy(name)
|
||||
ElMessage.success('Proxy deleted')
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
if (err !== 'cancel' && err !== 'close') {
|
||||
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (proxy: ProxyStatus) => {
|
||||
if (proxy.source !== 'store') return
|
||||
confirmAndDeleteProxy(proxy.name)
|
||||
}
|
||||
|
||||
const handleEditStoreProxy = (proxy: ProxyDefinition) => {
|
||||
router.push('/proxies/' + encodeURIComponent(proxy.name) + '/edit')
|
||||
}
|
||||
|
||||
const handleDeleteStoreProxy = async (proxy: ProxyDefinition) => {
|
||||
confirmAndDeleteProxy(proxy.name)
|
||||
}
|
||||
|
||||
const handleCreateVisitor = () => {
|
||||
router.push('/visitors/create')
|
||||
}
|
||||
|
||||
const handleEditVisitor = (visitor: VisitorDefinition) => {
|
||||
router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit')
|
||||
}
|
||||
|
||||
const handleDeleteVisitor = async (name: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Are you sure you want to delete visitor "${name}"? This action cannot be undone.`,
|
||||
'Delete Visitor',
|
||||
{
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
},
|
||||
)
|
||||
await deleteStoreVisitor(name)
|
||||
ElMessage.success('Visitor deleted')
|
||||
fetchData()
|
||||
} catch (err: any) {
|
||||
if (err !== 'cancel' && err !== 'close') {
|
||||
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
fetchData()
|
||||
</script>
|
||||
|
||||
@@ -222,19 +659,22 @@ fetchData()
|
||||
|
||||
.proxy-list-card,
|
||||
.types-card,
|
||||
.status-summary-card {
|
||||
.status-summary-card,
|
||||
.store-status-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
html.dark .proxy-list-card,
|
||||
html.dark .types-card,
|
||||
html.dark .status-summary-card {
|
||||
html.dark .status-summary-card,
|
||||
html.dark .store-status-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.status-summary-card {
|
||||
.status-summary-card,
|
||||
.store-status-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -255,7 +695,7 @@ html.dark .status-summary-card {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@@ -268,8 +708,12 @@ html.dark .card-title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.proxy-list-content {
|
||||
@@ -282,8 +726,45 @@ html.dark .card-title {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
html.dark .empty-icon {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
html.dark .empty-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0 0 20px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* Proxy Types Grid */
|
||||
@@ -303,6 +784,7 @@ html.dark .card-title {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proxy-type-item:hover {
|
||||
@@ -310,6 +792,19 @@ html.dark .card-title {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.proxy-type-item.active {
|
||||
background: var(--el-color-primary-light-8);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.proxy-type-item.active .proxy-type-name {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.proxy-type-item.active .proxy-type-count {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
html.dark .proxy-type-item {
|
||||
background: #1e1e2d;
|
||||
}
|
||||
@@ -318,6 +813,11 @@ html.dark .proxy-type-item:hover {
|
||||
background: #2a2a3c;
|
||||
}
|
||||
|
||||
html.dark .proxy-type-item.active {
|
||||
background: var(--el-color-primary-dark-2);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary);
|
||||
}
|
||||
|
||||
.proxy-type-name {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
@@ -410,6 +910,213 @@ html.dark .status-item:hover {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
/* Store Status Card */
|
||||
.store-info {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.store-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(102, 126, 234, 0.08) 0%,
|
||||
rgba(118, 75, 162, 0.08) 100%
|
||||
);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
html.dark .store-stat {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(129, 140, 248, 0.12) 0%,
|
||||
rgba(167, 139, 250, 0.12) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.store-stat-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
html.dark .store-stat-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.store-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
html.dark .store-stat-value {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.store-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.store-disabled-text {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Disabled Proxies Card */
|
||||
.disabled-proxies-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
html.dark .disabled-proxies-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.disabled-proxy-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.disabled-proxy-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
background: #faf7f0;
|
||||
border: 1px solid #f1d9a6;
|
||||
}
|
||||
|
||||
html.dark .disabled-proxy-card {
|
||||
background: rgba(161, 98, 7, 0.14);
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
.disabled-proxy-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.disabled-proxy-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .disabled-proxy-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.disabled-proxy-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.disabled-proxy-hint {
|
||||
margin: 12px 2px 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* Visitors Card */
|
||||
.visitors-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
html.dark .visitors-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.visitor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visitor-card {
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.visitor-card:hover {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
html.dark .visitor-card {
|
||||
background: #1e1e2d;
|
||||
}
|
||||
|
||||
html.dark .visitor-card:hover {
|
||||
background: #2a2a3c;
|
||||
}
|
||||
|
||||
.visitor-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.visitor-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visitor-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .visitor-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.visitor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.visitor-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
html.dark .visitor-card-body {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
@@ -432,10 +1139,21 @@ html.dark .status-item:hover {
|
||||
.proxy-types-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.disabled-proxy-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.disabled-proxy-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.status-summary-card {
|
||||
.status-summary-card,
|
||||
.store-status-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
1238
web/frpc/src/views/ProxyEdit.vue
Normal file
1238
web/frpc/src/views/ProxyEdit.vue
Normal file
File diff suppressed because it is too large
Load Diff
606
web/frpc/src/views/VisitorEdit.vue
Normal file
606
web/frpc/src/views/VisitorEdit.vue
Normal file
@@ -0,0 +1,606 @@
|
||||
<template>
|
||||
<div class="visitor-edit-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb">
|
||||
<a class="breadcrumb-link" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</a>
|
||||
<router-link to="/" class="breadcrumb-item">Overview</router-link>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span class="breadcrumb-current">{{
|
||||
isEditing ? 'Edit Visitor' : 'Create Visitor'
|
||||
}}</span>
|
||||
</nav>
|
||||
|
||||
<div v-loading="pageLoading" class="edit-content">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
@submit.prevent
|
||||
>
|
||||
<!-- Header Card -->
|
||||
<div class="form-card header-card">
|
||||
<div class="card-body">
|
||||
<div class="field-row three-col">
|
||||
<el-form-item label="Name" prop="name" class="field-grow">
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
:disabled="isEditing"
|
||||
placeholder="my-visitor"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Type" prop="type">
|
||||
<el-select
|
||||
v-model="form.type"
|
||||
:disabled="isEditing"
|
||||
:fit-input-width="false"
|
||||
popper-class="visitor-type-dropdown"
|
||||
class="type-select"
|
||||
>
|
||||
<el-option value="stcp" label="STCP">
|
||||
<div class="type-option">
|
||||
<span class="type-tag-inline type-stcp">STCP</span>
|
||||
<span class="type-desc">Secure TCP Visitor</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<el-option value="sudp" label="SUDP">
|
||||
<div class="type-option">
|
||||
<span class="type-tag-inline type-sudp">SUDP</span>
|
||||
<span class="type-desc">Secure UDP Visitor</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<el-option value="xtcp" label="XTCP">
|
||||
<div class="type-option">
|
||||
<span class="type-tag-inline type-xtcp">XTCP</span>
|
||||
<span class="type-desc">P2P (NAT traversal)</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Enabled">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection -->
|
||||
<div class="form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Connection</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Server Name" prop="serverName">
|
||||
<el-input
|
||||
v-model="form.serverName"
|
||||
placeholder="Name of the proxy to visit"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Server User">
|
||||
<el-input
|
||||
v-model="form.serverUser"
|
||||
placeholder="Leave empty for same user"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Secret Key">
|
||||
<el-input
|
||||
v-model="form.secretKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="Shared secret"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Bind Address">
|
||||
<el-input v-model="form.bindAddr" placeholder="127.0.0.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Bind Port" prop="bindPort">
|
||||
<el-input-number
|
||||
v-model="form.bindPort"
|
||||
:min="bindPortMin"
|
||||
:max="65535"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transport Options (collapsible) -->
|
||||
<div class="form-card collapsible-card">
|
||||
<div
|
||||
class="card-header clickable"
|
||||
@click="transportExpanded = !transportExpanded"
|
||||
>
|
||||
<h3 class="card-title">Transport Options</h3>
|
||||
<el-icon
|
||||
class="collapse-icon"
|
||||
:class="{ expanded: transportExpanded }"
|
||||
><ArrowDown
|
||||
/></el-icon>
|
||||
</div>
|
||||
<el-collapse-transition>
|
||||
<div v-show="transportExpanded" class="card-body">
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Use Encryption">
|
||||
<el-switch v-model="form.useEncryption" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Use Compression">
|
||||
<el-switch v-model="form.useCompression" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
|
||||
<!-- XTCP Options (collapsible, xtcp only) -->
|
||||
<template v-if="form.type === 'xtcp'">
|
||||
<div class="form-card collapsible-card">
|
||||
<div
|
||||
class="card-header clickable"
|
||||
@click="xtcpExpanded = !xtcpExpanded"
|
||||
>
|
||||
<h3 class="card-title">XTCP Options</h3>
|
||||
<el-icon class="collapse-icon" :class="{ expanded: xtcpExpanded }"
|
||||
><ArrowDown
|
||||
/></el-icon>
|
||||
</div>
|
||||
<el-collapse-transition>
|
||||
<div v-show="xtcpExpanded" class="card-body">
|
||||
<el-form-item label="Protocol">
|
||||
<el-select v-model="form.protocol" class="full-width">
|
||||
<el-option value="quic" label="QUIC" />
|
||||
<el-option value="kcp" label="KCP" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Keep Tunnel Open">
|
||||
<el-switch v-model="form.keepTunnelOpen" />
|
||||
</el-form-item>
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Max Retries per Hour">
|
||||
<el-input-number
|
||||
v-model="form.maxRetriesAnHour"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Min Retry Interval (s)">
|
||||
<el-input-number
|
||||
v-model="form.minRetryInterval"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="field-row two-col">
|
||||
<el-form-item label="Fallback To">
|
||||
<el-input
|
||||
v-model="form.fallbackTo"
|
||||
placeholder="Fallback visitor name"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Fallback Timeout (ms)">
|
||||
<el-input-number
|
||||
v-model="form.fallbackTimeoutMs"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
|
||||
<!-- NAT Traversal (collapsible, xtcp only) -->
|
||||
<div class="form-card collapsible-card">
|
||||
<div
|
||||
class="card-header clickable"
|
||||
@click="natExpanded = !natExpanded"
|
||||
>
|
||||
<h3 class="card-title">NAT Traversal</h3>
|
||||
<el-icon class="collapse-icon" :class="{ expanded: natExpanded }"
|
||||
><ArrowDown
|
||||
/></el-icon>
|
||||
</div>
|
||||
<el-collapse-transition>
|
||||
<div v-show="natExpanded" class="card-body">
|
||||
<el-form-item label="Disable Assisted Addresses">
|
||||
<el-switch v-model="form.natTraversalDisableAssistedAddrs" />
|
||||
<div class="form-tip">
|
||||
Only use STUN-discovered public addresses
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Footer -->
|
||||
<div class="sticky-footer">
|
||||
<div class="footer-content">
|
||||
<el-button @click="goBack">Cancel</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
{{ isEditing ? 'Update' : 'Create' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import {
|
||||
type VisitorFormData,
|
||||
createDefaultVisitorForm,
|
||||
formToStoreVisitor,
|
||||
storeVisitorToForm,
|
||||
} from '../types/proxy'
|
||||
import {
|
||||
getStoreVisitor,
|
||||
createStoreVisitor,
|
||||
updateStoreVisitor,
|
||||
} from '../api/frpc'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEditing = computed(() => !!route.params.name)
|
||||
const pageLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = ref<VisitorFormData>(createDefaultVisitorForm())
|
||||
|
||||
const transportExpanded = ref(false)
|
||||
const xtcpExpanded = ref(false)
|
||||
const natExpanded = ref(false)
|
||||
const bindPortMin = computed(() => (form.value.type === 'sudp' ? 1 : undefined))
|
||||
|
||||
const formRules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: 'Name is required', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: 'Length should be 1 to 50', trigger: 'blur' },
|
||||
],
|
||||
type: [{ required: true, message: 'Type is required', trigger: 'change' }],
|
||||
serverName: [
|
||||
{ required: true, message: 'Server name is required', trigger: 'blur' },
|
||||
],
|
||||
bindPort: [
|
||||
{ required: true, message: 'Bind port is required', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (value == null) {
|
||||
callback(new Error('Bind port is required'))
|
||||
return
|
||||
}
|
||||
if (value > 65535) {
|
||||
callback(new Error('Bind port must be less than or equal to 65535'))
|
||||
return
|
||||
}
|
||||
if (form.value.type === 'sudp') {
|
||||
if (value < 1) {
|
||||
callback(new Error('SUDP bind port must be greater than 0'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (value === 0) {
|
||||
callback(new Error('Bind port cannot be 0'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const loadVisitor = async () => {
|
||||
const name = route.params.name as string
|
||||
if (!name) return
|
||||
|
||||
pageLoading.value = true
|
||||
try {
|
||||
const res = await getStoreVisitor(name)
|
||||
form.value = storeVisitorToForm(res)
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Failed to load visitor: ' + err.message)
|
||||
router.push('/')
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
ElMessage.warning('Please fix the form errors')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data = formToStoreVisitor(form.value)
|
||||
if (isEditing.value) {
|
||||
await updateStoreVisitor(form.value.name, data)
|
||||
ElMessage.success('Visitor updated')
|
||||
} else {
|
||||
await createStoreVisitor(data)
|
||||
ElMessage.success('Visitor created')
|
||||
}
|
||||
router.push('/')
|
||||
} catch (err: any) {
|
||||
ElMessage.error('Operation failed: ' + (err.message || 'Unknown error'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEditing.value) {
|
||||
loadVisitor()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.name,
|
||||
(name, oldName) => {
|
||||
if (name === oldName) return
|
||||
if (name) {
|
||||
loadVisitor()
|
||||
return
|
||||
}
|
||||
form.value = createDefaultVisitorForm()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.visitor-edit-page {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form Cards */
|
||||
.form-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.dark .form-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
html.dark .card-header {
|
||||
border-bottom-color: #3a3d5c;
|
||||
}
|
||||
|
||||
.card-header.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.card-header.clickable:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.collapsible-card .card-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.collapsible-card .card-body {
|
||||
border-top: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
html.dark .collapsible-card .card-body {
|
||||
border-top-color: #3a3d5c;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
transition: transform 0.3s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.collapse-icon.expanded {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
/* Field Rows */
|
||||
.field-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-row.two-col {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.field-row.three-col {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.field-grow {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.type-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.type-tag-inline {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.type-tag-inline.type-stcp,
|
||||
.type-tag-inline.type-sudp,
|
||||
.type-tag-inline.type-xtcp {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Sticky Footer */
|
||||
.sticky-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 40px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.field-row.two-col,
|
||||
.field-row.three-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.visitor-type-dropdown {
|
||||
min-width: 300px !important;
|
||||
}
|
||||
|
||||
.visitor-type-dropdown .el-select-dropdown__item {
|
||||
height: auto;
|
||||
padding: 8px 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -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: ['Traffic', 'Proxies', 'Clients'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !noweb
|
||||
|
||||
package frps
|
||||
|
||||
import (
|
||||
|
||||
3
web/frps/embed_stub.go
Normal file
3
web/frps/embed_stub.go
Normal file
@@ -0,0 +1,3 @@
|
||||
//go:build noweb
|
||||
|
||||
package frps
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user