mirror of
https://github.com/fatedier/frp.git
synced 2026-07-30 01:12:53 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
541878af4d | ||
|
|
b7435967b0 | ||
|
|
774478d071 | ||
|
|
fbeb6ca43a | ||
|
|
381245a439 | ||
|
|
01997deb98 | ||
|
|
d0347325fc | ||
|
|
519368b1fd | ||
|
|
9634fd99d1 |
@@ -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
|
||||
|
||||
@@ -17,19 +17,19 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
go-version: '1.25'
|
||||
cache: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Build web assets (frps)
|
||||
run: make install build
|
||||
run: make build
|
||||
working-directory: web/frps
|
||||
- name: Build web assets (frpc)
|
||||
run: make install build
|
||||
run: make build
|
||||
working-directory: web/frpc
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||
version: v2.3
|
||||
version: v2.10
|
||||
|
||||
@@ -15,15 +15,15 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
go-version: '1.25'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Build web assets (frps)
|
||||
run: make install build
|
||||
run: make build
|
||||
working-directory: web/frps
|
||||
- name: Build web assets (frpc)
|
||||
run: make install build
|
||||
run: make build
|
||||
working-directory: web/frpc
|
||||
- name: Make All
|
||||
run: |
|
||||
|
||||
+2
-12
@@ -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/
|
||||
|
||||
+4
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-9
@@ -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 ?
|
||||
|
||||
+5
-4
@@ -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.
|
||||
|
||||
+11
-2
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+7
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+116
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
+24
-6
@@ -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)
|
||||
}
|
||||
|
||||
+56
-16
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+239
-75
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
-13
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+96
-30
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+36
-47
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -254,32 +226,20 @@ func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
||||
}
|
||||
cleared, total := mem.StatsCollector.ClearOfflineProxies()
|
||||
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
|
||||
return nil, nil
|
||||
return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
|
||||
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.ProxyStatsInfo) {
|
||||
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
|
||||
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
|
||||
proxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats))
|
||||
for _, ps := range proxyStats {
|
||||
proxyInfo := &ProxyStatsInfo{
|
||||
proxyInfo := &model.ProxyStatsInfo{
|
||||
User: ps.User,
|
||||
ClientID: ps.ClientID,
|
||||
}
|
||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||
content, err := json.Marshal(pxy.GetConfigurer())
|
||||
if err != nil {
|
||||
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
|
||||
continue
|
||||
}
|
||||
proxyInfo.Conf = getConfByType(ps.Type)
|
||||
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
|
||||
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
|
||||
continue
|
||||
}
|
||||
proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
|
||||
proxyInfo.Status = "online"
|
||||
c.fillProxyClientInfo(&proxyClientInfo{
|
||||
clientVersion: &proxyInfo.ClientVersion,
|
||||
}, pxy)
|
||||
} else {
|
||||
proxyInfo.Status = "offline"
|
||||
}
|
||||
@@ -294,7 +254,7 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
|
||||
func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo model.GetProxyStatsResp, code int, msg string) {
|
||||
proxyInfo.Name = proxyName
|
||||
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
|
||||
if ps == nil {
|
||||
@@ -304,20 +264,7 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
|
||||
proxyInfo.User = ps.User
|
||||
proxyInfo.ClientID = ps.ClientID
|
||||
if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
|
||||
content, err := json.Marshal(pxy.GetConfigurer())
|
||||
if err != nil {
|
||||
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
|
||||
code = 400
|
||||
msg = "parse conf error"
|
||||
return
|
||||
}
|
||||
proxyInfo.Conf = getConfByType(ps.Type)
|
||||
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
|
||||
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
|
||||
code = 400
|
||||
msg = "parse conf error"
|
||||
return
|
||||
}
|
||||
proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
|
||||
proxyInfo.Status = "online"
|
||||
} else {
|
||||
proxyInfo.Status = "offline"
|
||||
@@ -333,12 +280,13 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
|
||||
return
|
||||
}
|
||||
|
||||
func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
|
||||
resp := ClientInfoResp{
|
||||
func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp {
|
||||
resp := model.ClientInfoResp{
|
||||
Key: info.Key,
|
||||
User: info.User,
|
||||
ClientID: info.ClientID(),
|
||||
RunID: info.RunID,
|
||||
Version: info.Version,
|
||||
Hostname: info.Hostname,
|
||||
ClientIP: info.IP,
|
||||
FirstConnectedAt: toUnix(info.FirstConnectedAt),
|
||||
@@ -351,37 +299,6 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
|
||||
return resp
|
||||
}
|
||||
|
||||
type proxyClientInfo struct {
|
||||
user *string
|
||||
clientID *string
|
||||
clientVersion *string
|
||||
}
|
||||
|
||||
func (c *Controller) fillProxyClientInfo(proxyInfo *proxyClientInfo, pxy proxy.Proxy) {
|
||||
loginMsg := pxy.GetLoginMsg()
|
||||
if loginMsg == nil {
|
||||
return
|
||||
}
|
||||
if proxyInfo.user != nil {
|
||||
*proxyInfo.user = loginMsg.User
|
||||
}
|
||||
if proxyInfo.clientVersion != nil {
|
||||
*proxyInfo.clientVersion = loginMsg.Version
|
||||
}
|
||||
if info, ok := c.clientRegistry.GetByRunID(loginMsg.RunID); ok {
|
||||
if proxyInfo.clientID != nil {
|
||||
*proxyInfo.clientID = info.ClientID()
|
||||
}
|
||||
return
|
||||
}
|
||||
if proxyInfo.clientID != nil {
|
||||
*proxyInfo.clientID = loginMsg.ClientID
|
||||
if *proxyInfo.clientID == "" {
|
||||
*proxyInfo.clientID = loginMsg.RunID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toUnix(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
@@ -402,23 +319,37 @@ func matchStatusFilter(online bool, filter string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func getConfByType(proxyType string) any {
|
||||
switch v1.ProxyType(proxyType) {
|
||||
case v1.ProxyTypeTCP:
|
||||
return &TCPOutConf{}
|
||||
case v1.ProxyTypeTCPMUX:
|
||||
return &TCPMuxOutConf{}
|
||||
case v1.ProxyTypeUDP:
|
||||
return &UDPOutConf{}
|
||||
case v1.ProxyTypeHTTP:
|
||||
return &HTTPOutConf{}
|
||||
case v1.ProxyTypeHTTPS:
|
||||
return &HTTPSOutConf{}
|
||||
case v1.ProxyTypeSTCP:
|
||||
return &STCPOutConf{}
|
||||
case v1.ProxyTypeXTCP:
|
||||
return &XTCPOutConf{}
|
||||
default:
|
||||
return nil
|
||||
func getConfFromConfigurer(cfg v1.ProxyConfigurer) any {
|
||||
outBase := model.BaseOutConf{ProxyBaseConfig: *cfg.GetBaseConfig()}
|
||||
|
||||
switch c := cfg.(type) {
|
||||
case *v1.TCPProxyConfig:
|
||||
return &model.TCPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
|
||||
case *v1.UDPProxyConfig:
|
||||
return &model.UDPOutConf{BaseOutConf: outBase, RemotePort: c.RemotePort}
|
||||
case *v1.HTTPProxyConfig:
|
||||
return &model.HTTPOutConf{
|
||||
BaseOutConf: outBase,
|
||||
DomainConfig: c.DomainConfig,
|
||||
Locations: c.Locations,
|
||||
HostHeaderRewrite: c.HostHeaderRewrite,
|
||||
}
|
||||
case *v1.HTTPSProxyConfig:
|
||||
return &model.HTTPSOutConf{
|
||||
BaseOutConf: outBase,
|
||||
DomainConfig: c.DomainConfig,
|
||||
}
|
||||
case *v1.TCPMuxProxyConfig:
|
||||
return &model.TCPMuxOutConf{
|
||||
BaseOutConf: outBase,
|
||||
DomainConfig: c.DomainConfig,
|
||||
Multiplexer: c.Multiplexer,
|
||||
RouteByHTTPUser: c.RouteByHTTPUser,
|
||||
}
|
||||
case *v1.STCPProxyConfig:
|
||||
return &model.STCPOutConf{BaseOutConf: outBase}
|
||||
case *v1.XTCPProxyConfig:
|
||||
return &model.XTCPOutConf{BaseOutConf: outBase}
|
||||
}
|
||||
return outBase
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -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 == "":
|
||||
|
||||
+1
-42
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
install:
|
||||
@npm install
|
||||
|
||||
build:
|
||||
build: install
|
||||
@npm run build
|
||||
|
||||
dev:
|
||||
|
||||
Vendored
+9
-1
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//go:build noweb
|
||||
|
||||
package frpc
|
||||
@@ -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,
|
||||
]
|
||||
Generated
+3248
-1459
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)}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+755
-37
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
-1
@@ -3,7 +3,7 @@
|
||||
install:
|
||||
@npm install
|
||||
|
||||
build:
|
||||
build: install
|
||||
@npm run build
|
||||
|
||||
dev:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !noweb
|
||||
|
||||
package frps
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//go:build noweb
|
||||
|
||||
package frps
|
||||
@@ -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: ['Traffic', 'Proxies', 'Clients'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
skipFormatting,
|
||||
]
|
||||
Generated
+2897
-1905
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user