Compare commits

..

1 Commits

Author SHA1 Message Date
fatedier
aada7144d1 frpc: support store source config 2026-02-13 15:37:06 +08:00
98 changed files with 4592 additions and 10587 deletions

View File

@@ -2,7 +2,7 @@ version: 2
jobs:
go-version-latest:
docker:
- image: cimg/go:1.25-node
- image: cimg/go:1.24-node
resource_class: large
steps:
- checkout

View File

@@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.24'
cache: false
- uses: actions/setup-node@v4
with:
@@ -29,7 +29,7 @@ jobs:
run: make build
working-directory: web/frpc
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@v8
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.10
version: v2.3

View File

@@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.24'
- uses: actions/setup-node@v4
with:
node-version: '22'

1
.gitignore vendored
View File

@@ -30,5 +30,4 @@ client.key
# AI
CLAUDE.md
AGENTS.md
.sisyphus/

View File

@@ -33,7 +33,13 @@ linters:
disabled-checks:
- exitAfterDefer
gosec:
excludes: ["G115", "G117", "G204", "G401", "G402", "G404", "G501", "G703", "G704", "G705"]
excludes:
- G401
- G402
- G404
- G501
- G115
- G204
severity: low
confidence: low
govet:
@@ -71,9 +77,6 @@ linters:
- linters:
- revive
text: "avoid meaningless package names"
- linters:
- revive
text: "Go standard library package names"
- linters:
- unparam
text: is always false

View File

@@ -1,7 +1,6 @@
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
@@ -29,23 +28,23 @@ fmt-more:
gci:
gci write -s standard -s default -s "prefix(github.com/fatedier/frp/)" ./
vet:
go vet -tags "$(NOWEB_TAG)" ./...
vet: web
go vet ./...
frps:
env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags "frps$(NOWEB_TAG)" -o bin/frps ./cmd/frps
env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags frps -o bin/frps ./cmd/frps
frpc:
env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags "frpc$(NOWEB_TAG)" -o bin/frpc ./cmd/frpc
env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags frpc -o bin/frpc ./cmd/frpc
test: gotest
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/...
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/...
e2e:
./hack/run-e2e.sh

View File

@@ -13,16 +13,6 @@ 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">
@@ -50,6 +40,15 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<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?
@@ -801,14 +800,6 @@ 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

View File

@@ -15,16 +15,6 @@ 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">
@@ -52,6 +42,15 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<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

View File

@@ -1,9 +1,8 @@
## Features
* 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.
* 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.
## Improvements
## Fixes
* 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.
* Fixed UDP proxy protocol sending header on every packet instead of only the first packet of each session.

View File

@@ -29,23 +29,14 @@ 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
switch {
case prefixPath != "":
if prefixPath != "" {
FileSystem = http.Dir(prefixPath)
case content != nil:
} else {
FileSystem = http.FS(content)
default:
FileSystem = emptyFS{}
}
}

View File

@@ -17,7 +17,7 @@ package client
import (
"net/http"
adminapi "github.com/fatedier/frp/client/http"
"github.com/fatedier/frp/client/api"
"github.com/fatedier/frp/client/proxy"
httppkg "github.com/fatedier/frp/pkg/util/http"
netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -65,11 +65,16 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
func newAPIController(svr *Service) *adminapi.Controller {
manager := newServiceConfigManager(svr)
return adminapi.NewController(adminapi.ControllerParams{
ServerAddr: svr.common.ServerAddr,
Manager: manager,
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.UpdateConfigSource,
ReloadFromSources: svr.reloadConfigFromSources,
GracefulClose: svr.GracefulClose,
StoreSource: svr.storeSource,
})
}

498
client/api/controller.go Normal file
View File

@@ -0,0 +1,498 @@
// 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"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"slices"
"strconv"
"time"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
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 func() []*proxy.WorkingStatus
serverAddr string
configFilePath string
unsafeFeatures *security.UnsafeFeatures
updateConfig func(common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
reloadFromSources func() error
gracefulClose func(d time.Duration)
storeSource *source.StoreSource
}
// ControllerParams contains parameters for creating an APIController.
type ControllerParams struct {
GetProxyStatus func() []*proxy.WorkingStatus
ServerAddr string
ConfigFilePath string
UnsafeFeatures *security.UnsafeFeatures
UpdateConfig func(common *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
ReloadFromSources func() error
GracefulClose func(d time.Duration)
StoreSource *source.StoreSource
}
func NewController(params ControllerParams) *Controller {
return &Controller{
getProxyStatus: params.GetProxyStatus,
serverAddr: params.ServerAddr,
configFilePath: params.ConfigFilePath,
unsafeFeatures: params.UnsafeFeatures,
updateConfig: params.UpdateConfig,
reloadFromSources: params.ReloadFromSources,
gracefulClose: params.GracefulClose,
storeSource: params.StoreSource,
}
}
func (c *Controller) reloadFromSourcesOrError() error {
if err := c.reloadFromSources(); err != nil {
return httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to apply config: %v", err))
}
return nil
}
// Reload handles GET /api/reload
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
strictConfigMode := false
strictStr := ctx.Query("strictConfig")
if strictStr != "" {
strictConfigMode, _ = strconv.ParseBool(strictStr)
}
result, err := config.LoadClientConfigResult(c.configFilePath, strictConfigMode)
if err != nil {
log.Warnf("reload frpc proxy config error: %s", err.Error())
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
proxyCfgs := result.Proxies
visitorCfgs := result.Visitors
proxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers(
result.Common,
proxyCfgs,
visitorCfgs,
)
proxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation)
visitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation)
if _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, c.unsafeFeatures); err != nil {
log.Warnf("reload frpc proxy config error: %s", err.Error())
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
if err := c.updateConfig(result.Common, 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
}
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
}
}
// Check if proxy is from store
if c.storeSource != nil {
if c.storeSource.GetProxy(status.Name) != nil {
psr.Source = "store"
}
}
return psr
}
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
proxies, err := c.storeSource.GetAllProxies()
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to list proxies: %v", err))
}
resp := ProxyListResp{Proxies: make([]ProxyConfig, 0, len(proxies))}
for _, p := range proxies {
cfg, err := proxyConfigurerToMap(p)
if err != nil {
continue
}
resp.Proxies = append(resp.Proxies, ProxyConfig{
Name: p.GetBaseConfig().Name,
Type: p.GetBaseConfig().Type,
Config: cfg,
})
}
return resp, nil
}
func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
p := c.storeSource.GetProxy(name)
if p == nil {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name))
}
cfg, err := proxyConfigurerToMap(p)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return ProxyConfig{
Name: p.GetBaseConfig().Name,
Type: p.GetBaseConfig().Type,
Config: cfg,
}, nil
}
func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) {
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedProxyConfig
if err := json.Unmarshal(body, &typed); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.ProxyConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required")
}
typed.Complete()
if err := validation.ValidateProxyConfigurerForClient(typed.ProxyConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
}
if err := c.storeSource.AddProxy(typed.ProxyConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusConflict, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: created proxy %q", typed.ProxyConfigurer.GetBaseConfig().Name)
return nil, nil
}
func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedProxyConfig
if err := json.Unmarshal(body, &typed); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.ProxyConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid proxy config: type is required")
}
bodyName := typed.ProxyConfigurer.GetBaseConfig().Name
if bodyName != name {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name in URL must match name in body")
}
typed.Complete()
if err := validation.ValidateProxyConfigurerForClient(typed.ProxyConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
}
if err := c.storeSource.UpdateProxy(typed.ProxyConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: updated proxy %q", name)
return nil, nil
}
func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
if err := c.storeSource.RemoveProxy(name); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: deleted proxy %q", name)
return nil, nil
}
func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) {
visitors, err := c.storeSource.GetAllVisitors()
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("failed to list visitors: %v", err))
}
resp := VisitorListResp{Visitors: make([]VisitorConfig, 0, len(visitors))}
for _, v := range visitors {
cfg, err := visitorConfigurerToMap(v)
if err != nil {
continue
}
resp.Visitors = append(resp.Visitors, VisitorConfig{
Name: v.GetBaseConfig().Name,
Type: v.GetBaseConfig().Type,
Config: cfg,
})
}
return resp, nil
}
func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
v := c.storeSource.GetVisitor(name)
if v == nil {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name))
}
cfg, err := visitorConfigurerToMap(v)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return VisitorConfig{
Name: v.GetBaseConfig().Name,
Type: v.GetBaseConfig().Type,
Config: cfg,
}, nil
}
func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) {
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedVisitorConfig
if err := json.Unmarshal(body, &typed); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.VisitorConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required")
}
typed.Complete()
if err := validation.ValidateVisitorConfigurer(typed.VisitorConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
}
if err := c.storeSource.AddVisitor(typed.VisitorConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusConflict, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: created visitor %q", typed.VisitorConfigurer.GetBaseConfig().Name)
return nil, nil
}
func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var typed v1.TypedVisitorConfig
if err := json.Unmarshal(body, &typed); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if typed.VisitorConfigurer == nil {
return nil, httppkg.NewError(http.StatusBadRequest, "invalid visitor config: type is required")
}
bodyName := typed.VisitorConfigurer.GetBaseConfig().Name
if bodyName != name {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name in URL must match name in body")
}
typed.Complete()
if err := validation.ValidateVisitorConfigurer(typed.VisitorConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("validation error: %v", err))
}
if err := c.storeSource.UpdateVisitor(typed.VisitorConfigurer); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: updated visitor %q", name)
return nil, nil
}
func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
if err := c.storeSource.RemoveVisitor(name); err != nil {
return nil, httppkg.NewError(http.StatusNotFound, err.Error())
}
if err := c.reloadFromSourcesOrError(); err != nil {
return nil, err
}
log.Infof("store: deleted visitor %q", name)
return nil, nil
}
func proxyConfigurerToMap(p v1.ProxyConfigurer) (map[string]any, error) {
data, err := json.Marshal(p)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return m, nil
}
func visitorConfigurerToMap(v v1.VisitorConfigurer) (map[string]any, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return m, nil
}

View File

@@ -12,9 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package model
const SourceStore = "store"
package api
// StatusResp is the response for GET /api/status
type StatusResp map[string][]ProxyStatusResp
@@ -31,12 +29,31 @@ type ProxyStatusResp struct {
Source string `json:"source,omitempty"` // "store" or "config"
}
// ProxyConfig wraps proxy configuration for API requests/responses.
type ProxyConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
// VisitorConfig wraps visitor configuration for API requests/responses.
type VisitorConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
// ProxyListResp is the response for GET /api/store/proxies
type ProxyListResp struct {
Proxies []ProxyDefinition `json:"proxies"`
Proxies []ProxyConfig `json:"proxies"`
}
// VisitorListResp is the response for GET /api/store/visitors
type VisitorListResp struct {
Visitors []VisitorDefinition `json:"visitors"`
Visitors []VisitorConfig `json:"visitors"`
}
// ErrorResp represents an error response
type ErrorResp struct {
Error string `json:"error"`
}

View File

@@ -1,422 +0,0 @@
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)
}

View File

@@ -1,137 +0,0 @@
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)
}
}

View File

@@ -1,42 +0,0 @@
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)
}

View File

@@ -25,9 +25,9 @@ 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/util"
"github.com/fatedier/frp/pkg/util/wait"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -157,7 +157,7 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
return
}
startMsg.ProxyName = naming.StripUserPrefix(ctl.sessionCtx.Common.User, startMsg.ProxyName)
startMsg.ProxyName = util.StripUserPrefix(ctl.sessionCtx.Common.User, startMsg.ProxyName)
// dispatch this work connection to related proxy
ctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg)
@@ -168,7 +168,7 @@ 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
proxyName := naming.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
proxyName := util.StripUserPrefix(ctl.sessionCtx.Common.User, inMsg.ProxyName)
err := ctl.pm.StartProxy(proxyName, inMsg.RemoteAddr, inMsg.Error)
if err != nil {
xl.Warnf("[%s] start error: %v", proxyName, err)

View File

@@ -1,395 +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 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
}

View File

@@ -1,531 +0,0 @@
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)
}
}

View File

@@ -1,148 +0,0 @@
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
}
}

View File

@@ -1,107 +0,0 @@
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
}
}

View File

@@ -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())
}

View File

@@ -29,8 +29,8 @@ 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/util"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
)
@@ -116,7 +116,7 @@ func NewWrapper(
vnetController: vnetController,
xl: xl,
ctx: xlog.NewContext(ctx, xl),
wireName: naming.AddUserPrefix(clientCfg.User, baseInfo.Name),
wireName: util.AddUserPrefix(clientCfg.User, baseInfo.Name),
}
if baseInfo.HealthCheck.Type != "" && baseInfo.LocalPort > 0 {

View File

@@ -129,7 +129,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
return
}
if errRet := errors.PanicToError(func() {
xl.Tracef("get udp package from workConn, len: %d", len(udpMsg.Content))
xl.Tracef("get udp package from workConn: %s", udpMsg.Content)
readCh <- &udpMsg
}); errRet != nil {
xl.Infof("reader goroutine for udp work connection closed: %v", errRet)
@@ -145,7 +145,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
for rawMsg := range sendCh {
switch m := rawMsg.(type) {
case *msg.UDPPacket:
xl.Tracef("send udp package to workConn, len: %d", len(m.Content))
xl.Tracef("send udp package to workConn: %s", m.Content)
case *msg.Ping:
xl.Tracef("send ping message to udp workConn")
}

View File

@@ -27,10 +27,10 @@ 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"
"github.com/fatedier/frp/pkg/util/util"
)
func init() {
@@ -86,7 +86,7 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
transactionID := nathole.NewTransactionID()
natHoleClientMsg := &msg.NatHoleClient{
TransactionID: transactionID,
ProxyName: naming.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name),
ProxyName: util.AddUserPrefix(pxy.clientCfg.User, pxy.cfg.Name),
Sid: natHoleSidMsg.Sid,
MappedAddrs: prepareResult.Addrs,
AssistedAddrs: prepareResult.AssistedAddrs,

View File

@@ -123,11 +123,8 @@ type Service struct {
vnetController *vnet.Controller
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
cfgMu sync.RWMutex
common *v1.ClientCommonConfig
// reloadCommon is used for filtering/defaulting during config-source reloads.
// It can be updated by /api/reload without mutating startup-only common behavior.
reloadCommon *v1.ClientCommonConfig
@@ -444,28 +441,26 @@ func (svr *Service) UpdateConfigSource(
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.
// Update reloadCommon before ReplaceAll so the subsequent reload uses the
// same common config as /api/reload validation.
svr.cfgMu.Lock()
prevReloadCommon := svr.reloadCommon
svr.reloadCommon = common
svr.cfgMu.Unlock()
if err := svr.reloadConfigFromSourcesLocked(); err != nil {
if err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
svr.cfgMu.Lock()
svr.reloadCommon = prevReloadCommon
svr.cfgMu.Unlock()
return err
}
return nil
return svr.reloadConfigFromSources()
}
func (svr *Service) Close() {
@@ -478,15 +473,6 @@ 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 {
@@ -497,6 +483,11 @@ func (svr *Service) stop() {
svr.webServer.Close()
svr.webServer = nil
}
if svr.aggregator != nil {
svr.aggregator = nil
}
svr.configSource = nil
svr.storeSource = nil
}
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {
@@ -529,14 +520,7 @@ func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus,
}
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 {
if svr.aggregator == nil {
return errors.New("config aggregator is not initialized")
}
@@ -544,7 +528,7 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
reloadCommon := svr.reloadCommon
svr.cfgMu.RUnlock()
proxies, visitors, err := aggregator.Load()
proxies, visitors, err := svr.aggregator.Load()
if err != nil {
return fmt.Errorf("reload config from sources failed: %w", err)
}

View File

@@ -1,140 +0,0 @@
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)
}
}

View File

@@ -25,7 +25,6 @@ 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"
)
@@ -104,7 +103,7 @@ 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)
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
newVisitorConnMsg := &msg.NewVisitorConn{
RunID: sv.helper.RunID(),
ProxyName: targetProxyName,

View File

@@ -27,7 +27,6 @@ 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"
@@ -147,7 +146,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
case *msg.UDPPacket:
if errRet := errors.PanicToError(func() {
sv.readCh <- m
xl.Tracef("frpc visitor get udp packet from workConn, len: %d", len(m.Content))
xl.Tracef("frpc visitor get udp packet from workConn: %s", m.Content)
}); errRet != nil {
xl.Infof("reader goroutine for udp work connection closed")
return
@@ -169,7 +168,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
xl.Tracef("send udp package to workConn, len: %d", len(firstPacket.Content))
xl.Tracef("send udp package to workConn: %s", firstPacket.Content)
}
for {
@@ -184,7 +183,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
return
}
xl.Tracef("send udp package to workConn, len: %d", len(udpMsg.Content))
xl.Tracef("send udp package to workConn: %s", udpMsg.Content)
case <-closeCh:
return
}
@@ -206,7 +205,7 @@ func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) {
}
now := time.Now().Unix()
targetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
newVisitorConnMsg := &msg.NewVisitorConn{
RunID: sv.helper.RunID(),
ProxyName: targetProxyName,

View File

@@ -31,7 +31,6 @@ 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"
@@ -281,7 +280,7 @@ 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)
targetProxyName := util.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
xl.Tracef("makeNatHole start")
if err := nathole.PreCheck(sv.ctx, sv.helper.MsgTransporter(), targetProxyName, 5*time.Second); err != nil {
xl.Warnf("nathole precheck error: %v", err)

View File

@@ -143,9 +143,6 @@ 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.

View File

@@ -5,7 +5,7 @@ COPY web/frpc/ ./
RUN npm install
RUN npm run build
FROM golang:1.25 AS building
FROM golang:1.24 AS building
COPY . /building
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist

View File

@@ -5,7 +5,7 @@ COPY web/frps/ ./
RUN npm install
RUN npm run build
FROM golang:1.25 AS building
FROM golang:1.24 AS building
COPY . /building
COPY --from=web-builder /web/frps/dist /building/web/frps/dist

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/fatedier/frp
go 1.25.0
go 1.24.0
require (
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5

View File

@@ -17,7 +17,6 @@ package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -34,7 +33,6 @@ 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"
)
@@ -110,21 +108,7 @@ func LoadConfigureFromFile(path string, c any, strict bool) error {
if err != nil {
return err
}
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 ""
}
return LoadConfigure(content, c, strict)
}
// parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling
@@ -145,136 +129,48 @@ func parseYAMLWithDotFieldsHandling(content []byte, target any) error {
}
// Convert to JSON and decode with strict validation
jsonBytes, err := jsonx.Marshal(temp)
jsonBytes, err := json.Marshal(temp)
if err != nil {
return err
}
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,
})
decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
}
// LoadConfigure loads configuration from bytes and unmarshal into c.
// Now it supports json, yaml and toml format.
// 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
func LoadConfigure(b []byte, c any, strict bool) error {
v1.DisallowUnknownFieldsMu.Lock()
defer v1.DisallowUnknownFieldsMu.Unlock()
v1.DisallowUnknownFields = strict
var tomlObj any
tomlErr := toml.Unmarshal(b, &tomlObj)
if tomlErr == nil {
parsedFromTOML = true
var err error
b, err = jsonx.Marshal(&tomlObj)
// 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)
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) {
if err := decodeJSONContent(b, c, strict); err != nil {
return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
decoder := json.NewDecoder(bytes.NewBuffer(b))
if strict {
decoder.DisallowUnknownFields()
}
return nil
return decoder.Decode(c)
}
// Handle YAML content
if strict {
// In strict mode, always use our custom handler to support YAML merge
if err := parseYAMLWithDotFieldsHandling(b, c); err != nil {
return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
}
return nil
return parseYAMLWithDotFieldsHandling(b, c)
}
// 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))
@@ -445,8 +341,7 @@ func FilterClientConfigurers(
proxyCfgs := proxies
visitorCfgs := visitors
// Filter by start across merged configurers from all sources.
// For example, store entries are also filtered by this set.
// Filter by start
if len(common.Start) > 0 {
startSet := sets.New(common.Start...)
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {

View File

@@ -189,31 +189,6 @@ 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) {
@@ -495,111 +470,3 @@ 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)
}

View File

@@ -23,6 +23,10 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type sourceEntry struct {
source Source
}
type Aggregator struct {
mu sync.RWMutex
@@ -54,13 +58,17 @@ func (a *Aggregator) StoreSource() *StoreSource {
return a.storeSource
}
func (a *Aggregator) getSourcesLocked() []Source {
sources := make([]Source, 0, 2)
func (a *Aggregator) getSourcesLocked() []sourceEntry {
sources := make([]sourceEntry, 0, 2)
if a.configSource != nil {
sources = append(sources, a.configSource)
sources = append(sources, sourceEntry{
source: a.configSource,
})
}
if a.storeSource != nil {
sources = append(sources, a.storeSource)
sources = append(sources, sourceEntry{
source: a.storeSource,
})
}
return sources
}
@@ -77,8 +85,8 @@ func (a *Aggregator) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error
proxyMap := make(map[string]v1.ProxyConfigurer)
visitorMap := make(map[string]v1.VisitorConfigurer)
for _, src := range entries {
proxies, visitors, err := src.Load()
for _, entry := range entries {
proxies, visitors, err := entry.source.Load()
if err != nil {
return nil, nil, fmt.Errorf("load source: %w", err)
}

View File

@@ -196,7 +196,7 @@ func TestAggregator_VisitorMerge(t *testing.T) {
require.Len(visitors, 2)
}
func TestAggregator_Load_ReturnsDefensiveCopies(t *testing.T) {
func TestAggregator_Load_ReturnsSharedReferences(t *testing.T) {
require := require.New(t)
agg := newTestAggregator(t, nil)
@@ -213,5 +213,5 @@ func TestAggregator_Load_ReturnsDefensiveCopies(t *testing.T) {
proxies2, _, err := agg.Load()
require.NoError(err)
require.Len(proxies2, 1)
require.Equal("ssh", proxies2[0].GetBaseConfig().Name)
require.Equal("alice.ssh", proxies2[0].GetBaseConfig().Name)
}

View File

@@ -61,5 +61,5 @@ func (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error
visitors = append(visitors, v)
}
return cloneConfigurers(proxies, visitors)
return proxies, visitors, nil
}

View File

@@ -1,48 +0,0 @@
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)
}

View File

@@ -1,43 +0,0 @@
// 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
}

View File

@@ -15,13 +15,12 @@
package source
import (
"errors"
"encoding/json"
"fmt"
"os"
"path/filepath"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/util/jsonx"
)
type StoreSourceConfig struct {
@@ -38,11 +37,6 @@ type StoreSource struct {
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")
@@ -74,44 +68,34 @@ func (s *StoreSource) loadFromFileUnlocked() error {
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 {
var stored storeData
if err := json.Unmarshal(data, &stored); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
}
s.proxies = make(map[string]v1.ProxyConfigurer)
s.visitors = make(map[string]v1.VisitorConfigurer)
for 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)
for _, tp := range stored.Proxies {
if tp.ProxyConfigurer != nil {
proxyCfg := tp.ProxyConfigurer
name := proxyCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.proxies[name] = proxyCfg
}
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)
for _, tv := range stored.Visitors {
if tv.VisitorConfigurer != nil {
visitorCfg := tv.VisitorConfigurer
name := visitorCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.visitors[name] = visitorCfg
}
name := visitorCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.visitors[name] = visitorCfg
}
return nil
@@ -130,7 +114,7 @@ func (s *StoreSource) saveToFileUnlocked() error {
stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v})
}
data, err := jsonx.MarshalIndent(stored, "", " ")
data, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
@@ -186,7 +170,7 @@ func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
defer s.mu.Unlock()
if _, exists := s.proxies[name]; exists {
return fmt.Errorf("%w: proxy %q", ErrAlreadyExists, name)
return fmt.Errorf("proxy %q already exists", name)
}
s.proxies[name] = proxy
@@ -213,7 +197,7 @@ func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
return fmt.Errorf("proxy %q does not exist", name)
}
s.proxies[name] = proxy
@@ -235,7 +219,7 @@ func (s *StoreSource) RemoveProxy(name string) error {
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
return fmt.Errorf("proxy %q does not exist", name)
}
delete(s.proxies, name)
@@ -272,7 +256,7 @@ func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
defer s.mu.Unlock()
if _, exists := s.visitors[name]; exists {
return fmt.Errorf("%w: visitor %q", ErrAlreadyExists, name)
return fmt.Errorf("visitor %q already exists", name)
}
s.visitors[name] = visitor
@@ -299,7 +283,7 @@ func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
return fmt.Errorf("visitor %q does not exist", name)
}
s.visitors[name] = visitor
@@ -321,7 +305,7 @@ func (s *StoreSource) RemoveVisitor(name string) error {
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
return fmt.Errorf("visitor %q does not exist", name)
}
delete(s.visitors, name)

View File

@@ -15,6 +15,7 @@
package source
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@@ -22,7 +23,6 @@ import (
"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) {
@@ -80,7 +80,7 @@ func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}},
Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}},
}
data, err := jsonx.Marshal(stored)
data, err := json.Marshal(stored)
require.NoError(err)
err = os.WriteFile(path, data, 0o600)
require.NoError(err)
@@ -97,25 +97,3 @@ func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
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"))
}

View File

@@ -1,109 +0,0 @@
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)
}

View File

@@ -15,11 +15,23 @@
package v1
import (
"maps"
"sync"
"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 (
@@ -92,14 +104,6 @@ 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,
@@ -134,12 +138,6 @@ 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"`

View File

@@ -1,195 +0,0 @@
// 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
}

View File

@@ -1,86 +0,0 @@
// 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")
}

View File

@@ -15,13 +15,14 @@
package v1
import (
"maps"
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"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"
)
@@ -99,23 +100,11 @@ 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"`
@@ -131,22 +120,6 @@ 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
}
@@ -199,24 +172,40 @@ type TypedProxyConfig struct {
}
func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error {
configurer, err := DecodeProxyConfigurerJSON(b, DecodeOptions{})
if err != nil {
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 {
return err
}
c.Type = configurer.GetBaseConfig().Type
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.ProxyConfigurer = configurer
return nil
}
func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {
return jsonx.Marshal(c.ProxyConfigurer)
return json.Marshal(c.ProxyConfigurer)
}
type ProxyConfigurer interface {
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)
@@ -279,12 +268,6 @@ 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 {
@@ -305,12 +288,6 @@ 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 {
@@ -354,16 +331,6 @@ 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 {
@@ -385,13 +352,6 @@ 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 (
@@ -432,13 +392,6 @@ 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 {
@@ -462,13 +415,6 @@ 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 {
@@ -495,14 +441,6 @@ 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 {
@@ -525,10 +463,3 @@ 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
}

View File

@@ -15,11 +15,14 @@
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"
)
@@ -51,7 +54,6 @@ var clientPluginOptionsTypeMap = map[string]reflect.Type{
type ClientPluginOptions interface {
Complete()
Clone() ClientPluginOptions
}
type TypedClientPluginOptions struct {
@@ -59,25 +61,43 @@ type TypedClientPluginOptions struct {
ClientPluginOptions
}
func (c TypedClientPluginOptions) Clone() TypedClientPluginOptions {
out := c
if c.ClientPluginOptions != nil {
out.ClientPluginOptions = c.ClientPluginOptions.Clone()
}
return out
}
func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
decoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{})
if err != nil {
if len(b) == 4 && string(b) == "null" {
return nil
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
return err
}
*c = decoded
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
return nil
}
func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
return jsonx.Marshal(c.ClientPluginOptions)
return json.Marshal(c.ClientPluginOptions)
}
type HTTP2HTTPSPluginOptions struct {
@@ -89,15 +109,6 @@ 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"`
@@ -106,14 +117,6 @@ 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"`
@@ -128,16 +131,6 @@ 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"`
@@ -152,16 +145,6 @@ 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"`
@@ -171,15 +154,6 @@ 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"`
@@ -188,14 +162,6 @@ 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"`
@@ -206,14 +172,6 @@ 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"`
@@ -221,14 +179,6 @@ 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"`
@@ -238,24 +188,8 @@ 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
}

View File

@@ -15,9 +15,12 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -47,13 +50,6 @@ 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
}
@@ -67,7 +63,6 @@ func (c *VisitorBaseConfig) Complete() {
type VisitorConfigurer interface {
Complete()
GetBaseConfig() *VisitorBaseConfig
Clone() VisitorConfigurer
}
type VisitorType string
@@ -90,18 +85,35 @@ type TypedVisitorConfig struct {
}
func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error {
configurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{})
if err != nil {
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 {
return err
}
c.Type = configurer.GetBaseConfig().Type
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.VisitorConfigurer = configurer
return nil
}
func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) {
return jsonx.Marshal(c.VisitorConfigurer)
return json.Marshal(c.VisitorConfigurer)
}
func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer {
@@ -120,24 +132,12 @@ 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 {
@@ -162,10 +162,3 @@ func (c *XTCPVisitorConfig) Complete() {
c.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90)
c.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000)
}
func (c *XTCPVisitorConfig) Clone() VisitorConfigurer {
out := *c
out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
out.NatTraversal = c.NatTraversal.Clone()
return &out
}

View File

@@ -15,9 +15,11 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/fatedier/frp/pkg/util/jsonx"
)
const (
@@ -30,7 +32,6 @@ var visitorPluginOptionsTypeMap = map[string]reflect.Type{
type VisitorPluginOptions interface {
Complete()
Clone() VisitorPluginOptions
}
type TypedVisitorPluginOptions struct {
@@ -38,25 +39,43 @@ type TypedVisitorPluginOptions struct {
VisitorPluginOptions
}
func (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions {
out := c
if c.VisitorPluginOptions != nil {
out.VisitorPluginOptions = c.VisitorPluginOptions.Clone()
}
return out
}
func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
decoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{})
if err != nil {
if len(b) == 4 && string(b) == "null" {
return nil
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
return err
}
*c = decoded
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
return nil
}
func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) {
return jsonx.Marshal(c.VisitorPluginOptions)
return json.Marshal(c.VisitorPluginOptions)
}
type VirtualNetVisitorPluginOptions struct {
@@ -65,11 +84,3 @@ type VirtualNetVisitorPluginOptions struct {
}
func (o *VirtualNetVisitorPluginOptions) Complete() {}
func (o *VirtualNetVisitorPluginOptions) Clone() VisitorPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}

View File

@@ -184,7 +184,7 @@ type Pong struct {
}
type UDPPacket struct {
Content []byte `json:"c,omitempty"`
Content string `json:"c,omitempty"`
LocalAddr *net.UDPAddr `json:"l,omitempty"`
RemoteAddr *net.UDPAddr `json:"r,omitempty"`
}

View File

@@ -1,27 +0,0 @@
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"))
}

View File

@@ -375,7 +375,7 @@ func getRangePorts(addrs []string, difference, maxNumber int) []msg.PortsRange {
if !isLast {
return nil
}
ports := make([]msg.PortsRange, 0, 1)
var ports []msg.PortsRange
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil

View File

@@ -171,9 +171,8 @@ 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 {
enabled := f.enabled.Load().(map[Feature]bool)
pairs := make([]string, 0, len(enabled))
for k, v := range enabled {
pairs := []string{}
for k, v := range f.enabled.Load().(map[Feature]bool) {
pairs = append(pairs, fmt.Sprintf("%s=%t", k, v))
}
sort.Strings(pairs)

View File

@@ -15,6 +15,7 @@
package udp
import (
"encoding/base64"
"net"
"sync"
"time"
@@ -27,17 +28,16 @@ import (
)
func NewUDPPacket(buf []byte, laddr, raddr *net.UDPAddr) *msg.UDPPacket {
content := make([]byte, len(buf))
copy(content, buf)
return &msg.UDPPacket{
Content: content,
Content: base64.StdEncoding.EncodeToString(buf),
LocalAddr: laddr,
RemoteAddr: raddr,
}
}
func GetContent(m *msg.UDPPacket) (buf []byte, err error) {
return m.Content, nil
buf, err = base64.StdEncoding.DecodeString(m.Content)
return
}
func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh chan<- *msg.UDPPacket, bufSize int) {
@@ -60,7 +60,7 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh
if err != nil {
return
}
// NewUDPPacket copies buf[:n], so the read buffer can be reused
// buf[:n] will be encoded to string, so the bytes can be reused
udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr)
select {

View File

@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/fatedier/frp/client/http/model"
"github.com/fatedier/frp/client/api"
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) (*model.ProxyStatusResp, error) {
func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.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) (*model.ProxyS
if err != nil {
return nil, err
}
allStatus := make(model.StatusResp)
allStatus := make(api.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) (*model.ProxyS
return nil, fmt.Errorf("no proxy status found")
}
func (c *Client) GetAllProxyStatus(ctx context.Context) (model.StatusResp, error) {
func (c *Client) GetAllProxyStatus(ctx context.Context) (api.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) (model.StatusResp, error
if err != nil {
return nil, err
}
allStatus := make(model.StatusResp)
allStatus := make(api.StatusResp)
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
}

View File

@@ -1,45 +0,0 @@
// 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)
}

View File

@@ -1,36 +0,0 @@
// 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
}

View File

@@ -1,4 +1,4 @@
package naming
package util
import "strings"

View File

@@ -134,12 +134,3 @@ 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
}

View File

@@ -42,15 +42,22 @@ func TestParseRangeNumbers(t *testing.T) {
require.Error(err)
}
func TestClonePtr(t *testing.T) {
func TestAddUserPrefix(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)
require.Equal("test", AddUserPrefix("", "test"))
require.Equal("alice.test", AddUserPrefix("alice", "test"))
}
func TestStripUserPrefix(t *testing.T) {
require := require.New(t)
require.Equal("test", StripUserPrefix("", "test"))
require.Equal("test", StripUserPrefix("alice", "alice.test"))
require.Equal("alice.test", StripUserPrefix("alice", "alice.alice.test"))
require.Equal("bob.test", StripUserPrefix("alice", "bob.test"))
}
func TestBuildTargetServerProxyName(t *testing.T) {
require := require.New(t)
require.Equal("alice.test", BuildTargetServerProxyName("alice", "", "test"))
require.Equal("bob.test", BuildTargetServerProxyName("alice", "bob", "test"))
}

View File

@@ -14,7 +14,7 @@
package version
var version = "0.68.0"
var version = "0.67.0"
func Full() string {
return version

View File

@@ -12,10 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package http
package api
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"slices"
@@ -28,7 +29,6 @@ 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 := model.ServerInfoResp{
svrResp := ServerInfoResp{
Version: version.Full(),
BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
@@ -80,6 +80,22 @@ 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
}
@@ -96,7 +112,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
statusFilter := strings.ToLower(ctx.Query("status"))
records := c.clientRegistry.List()
items := make([]model.ClientInfoResp, 0, len(records))
items := make([]ClientInfoResp, 0, len(records))
for _, info := range records {
if userFilter != "" && info.User != userFilter {
continue
@@ -113,7 +129,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
items = append(items, buildClientInfoResp(info))
}
slices.SortFunc(items, func(a, b model.ClientInfoResp) int {
slices.SortFunc(items, func(a, b ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
@@ -149,9 +165,9 @@ func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type")
proxyInfoResp := model.GetProxyInfoResp{}
proxyInfoResp := GetProxyInfoResp{}
proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *model.ProxyStatsInfo) int {
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
return cmp.Compare(a.Name, b.Name)
})
@@ -175,7 +191,7 @@ func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {
func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
trafficResp := model.GetProxyTrafficResp{}
trafficResp := GetProxyTrafficResp{}
trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
@@ -197,7 +213,7 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
proxyInfo := model.GetProxyStatsResp{
proxyInfo := GetProxyStatsResp{
Name: ps.Name,
User: ps.User,
ClientID: ps.ClientID,
@@ -209,7 +225,16 @@ func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
}
if pxy, ok := c.pxyManager.GetByName(name); ok {
proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
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.Status = "online"
} else {
proxyInfo.Status = "offline"
@@ -229,16 +254,25 @@ func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil
}
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.ProxyStatsInfo) {
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
proxyInfos = make([]*model.ProxyStatsInfo, 0, len(proxyStats))
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats {
proxyInfo := &model.ProxyStatsInfo{
proxyInfo := &ProxyStatsInfo{
User: ps.User,
ClientID: ps.ClientID,
}
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
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.Status = "online"
} else {
proxyInfo.Status = "offline"
@@ -254,7 +288,7 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*model.
return
}
func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo model.GetProxyStatsResp, code int, msg string) {
func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
proxyInfo.Name = proxyName
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
if ps == nil {
@@ -264,7 +298,20 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
proxyInfo.User = ps.User
proxyInfo.ClientID = ps.ClientID
if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
proxyInfo.Conf = getConfFromConfigurer(pxy.GetConfigurer())
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.Status = "online"
} else {
proxyInfo.Status = "offline"
@@ -280,8 +327,8 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
return
}
func buildClientInfoResp(info registry.ClientInfo) model.ClientInfoResp {
resp := model.ClientInfoResp{
func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
resp := ClientInfoResp{
Key: info.Key,
User: info.User,
ClientID: info.ClientID(),
@@ -319,37 +366,23 @@ func matchStatusFilter(online bool, filter string) bool {
}
}
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}
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
}
return outBase
}

View File

@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package model
package api
import (
v1 "github.com/fatedier/frp/pkg/config/v1"

View File

@@ -1,64 +0,0 @@
// 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)
}

View File

@@ -100,9 +100,8 @@ func (tg *TCPGroup) Listen(proxyName string, group string, groupKey string, addr
if err != nil {
return
}
tcpLn, errRet := net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(realPort)))
tcpLn, errRet := net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(port)))
if errRet != nil {
tg.ctl.portManager.Release(realPort)
err = errRet
return
}

View File

@@ -1,71 +0,0 @@
// 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)
}
}

View File

@@ -136,7 +136,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
continue
case *msg.UDPPacket:
if errRet := errors.PanicToError(func() {
xl.Tracef("get udp message from workConn, len: %d", len(m.Content))
xl.Tracef("get udp message from workConn: %s", m.Content)
pxy.readCh <- m
metrics.Server.AddTrafficOut(
pxy.GetName(),
@@ -167,7 +167,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
conn.Close()
return
}
xl.Tracef("send message to udp workConn, len: %d", len(udpMsg.Content))
xl.Tracef("send message to udp workConn: %s", udpMsg.Content)
metrics.Server.AddTrafficIn(
pxy.GetName(),
pxy.GetConfigurer().GetBaseConfig().Type,

View File

@@ -28,6 +28,7 @@ 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"
@@ -47,6 +48,7 @@ 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"
@@ -688,3 +690,42 @@ 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)
}

View File

@@ -34,13 +34,11 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
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,
},
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
}
proxyBody, _ := json.Marshal(proxyConfig)
@@ -75,13 +73,11 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
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,
},
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort1,
}
proxyBody, _ := json.Marshal(proxyConfig)
@@ -96,7 +92,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort1).Ensure()
proxyConfig["tcp"].(map[string]any)["remotePort"] = remotePort2
proxyConfig["remotePort"] = remotePort2
proxyBody, _ = json.Marshal(proxyConfig)
framework.NewRequestExpect(f).RequestModify(func(r *request.Request) {
@@ -109,7 +105,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort2).Ensure()
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true).Ensure()
framework.NewRequestExpect(f).Port(remotePort1).ExpectError(true)
})
ginkgo.It("delete proxy via API", func() {
@@ -129,13 +125,11 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
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,
},
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
}
proxyBody, _ := json.Marshal(proxyConfig)
@@ -157,7 +151,7 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
})
time.Sleep(time.Second)
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true).Ensure()
framework.NewRequestExpect(f).Port(remotePort).ExpectError(true)
})
ginkgo.It("list and get proxy via API", func() {
@@ -177,13 +171,11 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
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,
},
"name": "test-tcp",
"type": "tcp",
"localIP": "127.0.0.1",
"localPort": f.PortByName(framework.TCPEchoServerPort),
"remotePort": remotePort,
}
proxyBody, _ := json.Marshal(proxyConfig)
@@ -234,90 +226,5 @@ var _ = ginkgo.Describe("[Feature: Store]", func() {
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
})
})
})
})

14
todo.md Normal file
View File

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

30
web/frpc/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,30 @@
/* 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'],
},
],
},
}

View File

@@ -11,6 +11,8 @@ declare module 'vue' {
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']

View File

@@ -1,5 +1,3 @@
//go:build !noweb
package frpc
import (

View File

@@ -1,3 +0,0 @@
//go:build noweb
package frpc

View File

@@ -1,36 +0,0 @@
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,
]

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,13 @@
"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 --fix"
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"element-plus": "^2.13.0",
@@ -17,13 +16,14 @@
"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": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
"eslint": "^9.39.0",
"eslint": "^8.56.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"
}
}
}

View File

@@ -66,11 +66,9 @@ 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.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'
if (route.path.startsWith('/visitors/') && route.path.endsWith('/edit')) return 'Edit Visitor'
return ''
})
</script>

View File

@@ -1,10 +1,10 @@
import { http } from './http'
import type {
StatusResponse,
ProxyListResp,
ProxyDefinition,
VisitorListResp,
VisitorDefinition,
StoreProxyListResp,
StoreProxyConfig,
StoreVisitorListResp,
StoreVisitorConfig,
} from '../types/proxy'
export const getStatus = () => {
@@ -25,21 +25,21 @@ export const reloadConfig = () => {
// Store API - Proxies
export const listStoreProxies = () => {
return http.get<ProxyListResp>('/api/store/proxies')
return http.get<StoreProxyListResp>('/api/store/proxies')
}
export const getStoreProxy = (name: string) => {
return http.get<ProxyDefinition>(
return http.get<StoreProxyConfig>(
`/api/store/proxies/${encodeURIComponent(name)}`,
)
}
export const createStoreProxy = (config: ProxyDefinition) => {
return http.post<ProxyDefinition>('/api/store/proxies', config)
export const createStoreProxy = (config: Record<string, any>) => {
return http.post<void>('/api/store/proxies', config)
}
export const updateStoreProxy = (name: string, config: ProxyDefinition) => {
return http.put<ProxyDefinition>(
export const updateStoreProxy = (name: string, config: Record<string, any>) => {
return http.put<void>(
`/api/store/proxies/${encodeURIComponent(name)}`,
config,
)
@@ -51,24 +51,24 @@ export const deleteStoreProxy = (name: string) => {
// Store API - Visitors
export const listStoreVisitors = () => {
return http.get<VisitorListResp>('/api/store/visitors')
return http.get<StoreVisitorListResp>('/api/store/visitors')
}
export const getStoreVisitor = (name: string) => {
return http.get<VisitorDefinition>(
return http.get<StoreVisitorConfig>(
`/api/store/visitors/${encodeURIComponent(name)}`,
)
}
export const createStoreVisitor = (config: VisitorDefinition) => {
return http.post<VisitorDefinition>('/api/store/visitors', config)
export const createStoreVisitor = (config: Record<string, any>) => {
return http.post<void>('/api/store/visitors', config)
}
export const updateStoreVisitor = (
name: string,
config: VisitorDefinition,
config: Record<string, any>,
) => {
return http.put<VisitorDefinition>(
return http.put<void>(
`/api/store/visitors/${encodeURIComponent(name)}`,
config,
)

View File

@@ -30,10 +30,7 @@
</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"
/>
<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>

View File

@@ -412,22 +412,16 @@ html.dark .source-tag {
/* Action buttons */
.card-actions {
display: flex;
display: none;
gap: 4px;
}
@media (hover: hover) and (pointer: fine) {
.card-actions {
display: none;
}
.proxy-card.is-store:hover .status-badge {
display: none;
}
.proxy-card.is-store:hover .status-badge {
display: none;
}
.proxy-card:hover .card-actions {
display: flex;
}
.proxy-card:hover .card-actions {
display: flex;
}
.action-btn {
@@ -489,5 +483,10 @@ html.dark .delete-btn:hover {
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 14px;
}
.card-actions {
opacity: 1;
transform: none;
}
}
</style>

View File

@@ -1,10 +1,8 @@
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(),
@@ -23,55 +21,23 @@ const router = createRouter({
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

View File

@@ -20,33 +20,24 @@ export type StatusResponse = Record<string, ProxyStatus[]>
// STORE API TYPES
// ========================================
export interface ProxyDefinition {
export interface StoreProxyConfig {
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>
type: string
config: Record<string, any>
}
export interface VisitorDefinition {
export interface StoreVisitorConfig {
name: string
type: VisitorType
stcp?: Record<string, any>
sudp?: Record<string, any>
xtcp?: Record<string, any>
type: string
config: Record<string, any>
}
export interface ProxyListResp {
proxies: ProxyDefinition[]
export interface StoreProxyListResp {
proxies: StoreProxyConfig[]
}
export interface VisitorListResp {
visitors: VisitorDefinition[]
export interface StoreVisitorListResp {
visitors: StoreVisitorConfig[]
}
// ========================================
@@ -264,24 +255,29 @@ export function createDefaultVisitorForm(): VisitorFormData {
// CONVERTERS: Form -> Store API
// ========================================
export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
const block: Record<string, any> = {}
export function formToStoreProxy(form: ProxyFormData): Record<string, any> {
const config: Record<string, any> = {
name: form.name,
type: form.type,
}
// Enabled (nil/true = enabled, false = disabled)
if (!form.enabled) {
block.enabled = false
config.enabled = false
}
// Backend - LocalIP/LocalPort
if (form.pluginType === '') {
// No plugin, use local backend
if (form.localIP && form.localIP !== '127.0.0.1') {
block.localIP = form.localIP
config.localIP = form.localIP
}
if (form.localPort != null) {
block.localPort = form.localPort
config.localPort = form.localPort
}
} else {
block.plugin = {
// Plugin backend
config.plugin = {
type: form.pluginType,
...form.pluginConfig,
}
@@ -295,102 +291,109 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
(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
config.transport = {}
if (form.useEncryption) config.transport.useEncryption = true
if (form.useCompression) config.transport.useCompression = true
if (form.bandwidthLimit)
config.transport.bandwidthLimit = form.bandwidthLimit
if (form.bandwidthLimitMode && form.bandwidthLimitMode !== 'client') {
block.transport.bandwidthLimitMode = form.bandwidthLimitMode
config.transport.bandwidthLimitMode = form.bandwidthLimitMode
}
if (form.proxyProtocolVersion) {
block.transport.proxyProtocolVersion = form.proxyProtocolVersion
config.transport.proxyProtocolVersion = form.proxyProtocolVersion
}
}
// Load Balancer
if (form.loadBalancerGroup) {
block.loadBalancer = {
config.loadBalancer = {
group: form.loadBalancerGroup,
}
if (form.loadBalancerGroupKey) {
block.loadBalancer.groupKey = form.loadBalancerGroupKey
config.loadBalancer.groupKey = form.loadBalancerGroupKey
}
}
// Health Check
if (form.healthCheckType) {
block.healthCheck = {
config.healthCheck = {
type: form.healthCheckType,
}
if (form.healthCheckTimeoutSeconds != null) {
block.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds
config.healthCheck.timeoutSeconds = form.healthCheckTimeoutSeconds
}
if (form.healthCheckMaxFailed != null) {
block.healthCheck.maxFailed = form.healthCheckMaxFailed
config.healthCheck.maxFailed = form.healthCheckMaxFailed
}
if (form.healthCheckIntervalSeconds != null) {
block.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds
config.healthCheck.intervalSeconds = form.healthCheckIntervalSeconds
}
if (form.healthCheckPath) {
block.healthCheck.path = form.healthCheckPath
config.healthCheck.path = form.healthCheckPath
}
if (form.healthCheckHTTPHeaders.length > 0) {
block.healthCheck.httpHeaders = form.healthCheckHTTPHeaders
config.healthCheck.httpHeaders = form.healthCheckHTTPHeaders
}
}
// Metadata
if (form.metadatas.length > 0) {
block.metadatas = Object.fromEntries(
config.metadatas = Object.fromEntries(
form.metadatas.map((m) => [m.key, m.value]),
)
}
// Annotations
if (form.annotations.length > 0) {
block.annotations = Object.fromEntries(
config.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 === 'tcp' || form.type === 'udp') {
if (form.remotePort != null) {
config.remotePort = form.remotePort
}
}
if (form.type === 'http' || form.type === 'https' || form.type === 'tcpmux') {
// Domain config
if (form.customDomains) {
block.customDomains = form.customDomains
config.customDomains = form.customDomains
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (form.subdomain) {
block.subdomain = form.subdomain
config.subdomain = form.subdomain
}
}
if (form.type === 'http') {
// HTTP specific
if (form.locations) {
block.locations = form.locations
config.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.httpUser) config.httpUser = form.httpUser
if (form.httpPassword) config.httpPassword = form.httpPassword
if (form.hostHeaderRewrite)
config.hostHeaderRewrite = form.hostHeaderRewrite
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
// Header operations
if (form.requestHeaders.length > 0) {
block.requestHeaders = {
config.requestHeaders = {
set: Object.fromEntries(
form.requestHeaders.map((h) => [h.key, h.value]),
),
}
}
if (form.responseHeaders.length > 0) {
block.responseHeaders = {
config.responseHeaders = {
set: Object.fromEntries(
form.responseHeaders.map((h) => [h.key, h.value]),
),
@@ -399,194 +402,107 @@ export function formToStoreProxy(form: ProxyFormData): ProxyDefinition {
}
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
// TCPMux specific
if (form.httpUser) config.httpUser = form.httpUser
if (form.httpPassword) config.httpPassword = form.httpPassword
if (form.routeByHTTPUser) config.routeByHTTPUser = form.routeByHTTPUser
if (form.multiplexer && form.multiplexer !== 'httpconnect') {
block.multiplexer = form.multiplexer
config.multiplexer = form.multiplexer
}
}
if (form.type === 'stcp' || form.type === 'sudp' || form.type === 'xtcp') {
if (form.secretKey) block.secretKey = form.secretKey
// Secure proxy types
if (form.secretKey) config.secretKey = form.secretKey
if (form.allowUsers) {
block.allowUsers = form.allowUsers
config.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
}
// XTCP NAT traversal
if (form.natTraversalDisableAssistedAddrs) {
block.natTraversal = {
config.natTraversal = {
disableAssistedAddrs: true,
}
}
}
return withStoreVisitorBlock(
{
name: form.name,
type: form.type,
},
form.type,
block,
)
return config
}
export function formToStoreVisitor(form: VisitorFormData): Record<string, any> {
const config: Record<string, any> = {
name: form.name,
type: form.type,
}
// Enabled
if (!form.enabled) {
config.enabled = false
}
// Transport
if (form.useEncryption || form.useCompression) {
config.transport = {}
if (form.useEncryption) config.transport.useEncryption = true
if (form.useCompression) config.transport.useCompression = true
}
// Base fields
if (form.secretKey) config.secretKey = form.secretKey
if (form.serverUser) config.serverUser = form.serverUser
if (form.serverName) config.serverName = form.serverName
if (form.bindAddr && form.bindAddr !== '127.0.0.1') {
config.bindAddr = form.bindAddr
}
if (form.bindPort != null) {
config.bindPort = form.bindPort
}
// XTCP specific
if (form.type === 'xtcp') {
if (form.protocol && form.protocol !== 'quic') {
config.protocol = form.protocol
}
if (form.keepTunnelOpen) {
config.keepTunnelOpen = true
}
if (form.maxRetriesAnHour != null) {
config.maxRetriesAnHour = form.maxRetriesAnHour
}
if (form.minRetryInterval != null) {
config.minRetryInterval = form.minRetryInterval
}
if (form.fallbackTo) {
config.fallbackTo = form.fallbackTo
}
if (form.fallbackTimeoutMs != null) {
config.fallbackTimeoutMs = form.fallbackTimeoutMs
}
if (form.natTraversalDisableAssistedAddrs) {
config.natTraversal = {
disableAssistedAddrs: true,
}
}
}
return config
}
// ========================================
// CONVERTERS: Store API -> Form
// ========================================
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)
export function storeProxyToForm(config: StoreProxyConfig): ProxyFormData {
const c = config.config || {}
const form = createDefaultProxyForm()
form.name = config.name || ''
form.type = config.type || 'tcp'
form.type = (config.type as ProxyType) || 'tcp'
form.enabled = c.enabled !== false
// Backend
@@ -692,13 +608,13 @@ export function storeProxyToForm(config: ProxyDefinition): ProxyFormData {
}
export function storeVisitorToForm(
config: VisitorDefinition,
config: StoreVisitorConfig,
): VisitorFormData {
const c = getStoreVisitorBlock(config)
const c = config.config || {}
const form = createDefaultVisitorForm()
form.name = config.name || ''
form.type = config.type || 'stcp'
form.type = (config.type as VisitorType) || 'stcp'
form.enabled = c.enabled !== false
// Transport

View File

@@ -236,69 +236,15 @@
</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-row v-if="storeEnabled && storeVisitors.length > 0" :gutter="20">
<el-col :span="24">
<el-card class="visitors-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="card-title">Store Visitors</span>
<el-tag size="small" type="info"
>{{ storeVisitors.length }} visitors</el-tag
>
<el-tag size="small" type="info">{{ storeVisitors.length }} visitors</el-tag>
</div>
<el-tooltip content="Add new visitor" placement="top">
<el-button
@@ -310,7 +256,7 @@
</el-tooltip>
</div>
</template>
<div v-if="storeVisitors.length > 0" class="visitor-list">
<div class="visitor-list">
<div
v-for="visitor in storeVisitors"
:key="visitor.name"
@@ -319,9 +265,7 @@
<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>
<el-tag size="small" type="info">{{ visitor.type.toUpperCase() }}</el-tag>
</div>
<div class="visitor-actions">
<el-button size="small" @click="handleEditVisitor(visitor)">
@@ -337,41 +281,19 @@
</div>
</div>
<div class="visitor-card-body">
<span v-if="getVisitorBlock(visitor)?.serverName">
Server: {{ getVisitorBlock(visitor)?.serverName }}
<span v-if="visitor.config?.serverName">
Server: {{ visitor.config.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 v-if="visitor.config?.bindAddr || visitor.config?.bindPort">
Bind: {{ visitor.config.bindAddr || '127.0.0.1' }}:{{ visitor.config.bindPort }}
</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>
</template>
@@ -389,8 +311,8 @@ import {
} from '../api/frpc'
import type {
ProxyStatus,
ProxyDefinition,
VisitorDefinition,
StoreProxyConfig,
StoreVisitorConfig,
} from '../types/proxy'
import StatCard from '../components/StatCard.vue'
import ProxyCard from '../components/ProxyCard.vue'
@@ -399,8 +321,8 @@ const router = useRouter()
// State
const status = ref<ProxyStatus[]>([])
const storeProxies = ref<ProxyDefinition[]>([])
const storeVisitors = ref<VisitorDefinition[]>([])
const storeProxies = ref<StoreProxyConfig[]>([])
const storeVisitors = ref<StoreVisitorConfig[]>([])
const storeEnabled = ref(false)
const loading = ref(false)
const searchText = ref('')
@@ -463,41 +385,6 @@ const filteredStatus = computed(() => {
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) => {
@@ -550,11 +437,9 @@ const fetchStoreVisitors = async () => {
const fetchData = async () => {
loading.value = true
try {
await Promise.all([
fetchStoreProxies(),
fetchStoreVisitors(),
fetchStatus(),
])
await fetchStoreProxies()
await fetchStoreVisitors()
await fetchStatus()
} finally {
loading.value = false
}
@@ -569,46 +454,34 @@ const handleEdit = (proxy: ProxyStatus) => {
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) => {
const handleDelete = (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)
ElMessageBox.confirm(
`Are you sure you want to delete "${proxy.name}"? This action cannot be undone.`,
'Delete Proxy',
{
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning',
confirmButtonClass: 'el-button--danger',
},
).then(async () => {
try {
await deleteStoreProxy(proxy.name)
ElMessage.success('Proxy deleted')
fetchData()
} catch (err: any) {
ElMessage.error('Delete failed: ' + err.message)
}
})
}
const handleCreateVisitor = () => {
router.push('/visitors/create')
}
const handleEditVisitor = (visitor: VisitorDefinition) => {
const handleEditVisitor = (visitor: StoreVisitorConfig) => {
router.push('/visitors/' + encodeURIComponent(visitor.name) + '/edit')
}
@@ -622,13 +495,13 @@ const handleDeleteVisitor = async (name: string) => {
cancelButtonText: 'Cancel',
type: 'warning',
confirmButtonClass: 'el-button--danger',
},
}
)
await deleteStoreVisitor(name)
ElMessage.success('Visitor deleted')
fetchData()
} catch (err: any) {
if (err !== 'cancel' && err !== 'close') {
if (err !== 'cancel') {
ElMessage.error('Delete failed: ' + (err.message || 'Unknown error'))
}
}
@@ -977,69 +850,6 @@ html.dark .store-stat-value {
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;
@@ -1139,16 +949,6 @@ html.dark .visitor-card-body {
.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) {

View File

@@ -7,9 +7,7 @@
</a>
<router-link to="/" class="breadcrumb-item">Overview</router-link>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{
isEditing ? 'Edit Proxy' : 'Create Proxy'
}}</span>
<span class="breadcrumb-current">{{ isEditing ? 'Edit Proxy' : 'Create Proxy' }}</span>
</nav>
<div v-loading="pageLoading" class="edit-content">
@@ -65,9 +63,7 @@
:value="t"
>
<div class="type-option">
<span class="type-tag-inline" :class="`type-${t}`">{{
t.toUpperCase()
}}</span>
<span class="type-tag-inline" :class="`type-${t}`">{{ t.toUpperCase() }}</span>
<span class="type-desc">{{ typeDescs[t] }}</span>
</div>
</el-option>
@@ -96,10 +92,7 @@
<template v-if="backendMode === 'direct'">
<div class="field-row two-col">
<el-form-item label="Local IP" prop="localIP">
<el-input
v-model="form.localIP"
placeholder="127.0.0.1"
/>
<el-input v-model="form.localIP" placeholder="127.0.0.1" />
</el-form-item>
<el-form-item label="Local Port" prop="localPort">
<el-input-number
@@ -127,81 +120,39 @@
</el-form-item>
<!-- Plugin-specific fields -->
<template
v-if="
[
'http2https',
'https2http',
'https2https',
'http2http',
'tls2raw',
].includes(form.pluginType)
"
>
<template v-if="['http2https', 'https2http', 'https2https', 'http2http', 'tls2raw'].includes(form.pluginType)">
<el-form-item label="Local Address">
<el-input
v-model="form.pluginConfig.localAddr"
placeholder="127.0.0.1:8080"
/>
<el-input v-model="form.pluginConfig.localAddr" placeholder="127.0.0.1:8080" />
</el-form-item>
</template>
<template
v-if="
[
'http2https',
'https2http',
'https2https',
'http2http',
].includes(form.pluginType)
"
>
<template v-if="['http2https', 'https2http', 'https2https', 'http2http'].includes(form.pluginType)">
<el-form-item label="Host Header Rewrite">
<el-input v-model="form.pluginConfig.hostHeaderRewrite" />
</el-form-item>
<el-form-item label="Request Headers">
<KeyValueEditor
v-model="pluginRequestHeaders"
key-placeholder="Header"
value-placeholder="Value"
/>
<KeyValueEditor v-model="pluginRequestHeaders" key-placeholder="Header" value-placeholder="Value" />
</el-form-item>
</template>
<template
v-if="
['https2http', 'https2https'].includes(form.pluginType)
"
>
<template v-if="['https2http', 'https2https'].includes(form.pluginType)">
<el-form-item label="Enable HTTP/2">
<el-switch v-model="form.pluginConfig.enableHTTP2" />
</el-form-item>
<div class="field-row two-col">
<el-form-item label="Certificate Path">
<el-input
v-model="form.pluginConfig.crtPath"
placeholder="/path/to/cert.pem"
/>
<el-input v-model="form.pluginConfig.crtPath" placeholder="/path/to/cert.pem" />
</el-form-item>
<el-form-item label="Key Path">
<el-input
v-model="form.pluginConfig.keyPath"
placeholder="/path/to/key.pem"
/>
<el-input v-model="form.pluginConfig.keyPath" placeholder="/path/to/key.pem" />
</el-form-item>
</div>
</template>
<template v-if="form.pluginType === 'tls2raw'">
<div class="field-row two-col">
<el-form-item label="Certificate Path">
<el-input
v-model="form.pluginConfig.crtPath"
placeholder="/path/to/cert.pem"
/>
<el-input v-model="form.pluginConfig.crtPath" placeholder="/path/to/cert.pem" />
</el-form-item>
<el-form-item label="Key Path">
<el-input
v-model="form.pluginConfig.keyPath"
placeholder="/path/to/key.pem"
/>
<el-input v-model="form.pluginConfig.keyPath" placeholder="/path/to/key.pem" />
</el-form-item>
</div>
</template>
@@ -211,11 +162,7 @@
<el-input v-model="form.pluginConfig.httpUser" />
</el-form-item>
<el-form-item label="HTTP Password">
<el-input
v-model="form.pluginConfig.httpPassword"
type="password"
show-password
/>
<el-input v-model="form.pluginConfig.httpPassword" type="password" show-password />
</el-form-item>
</div>
</template>
@@ -225,20 +172,13 @@
<el-input v-model="form.pluginConfig.username" />
</el-form-item>
<el-form-item label="Password">
<el-input
v-model="form.pluginConfig.password"
type="password"
show-password
/>
<el-input v-model="form.pluginConfig.password" type="password" show-password />
</el-form-item>
</div>
</template>
<template v-if="form.pluginType === 'static_file'">
<el-form-item label="Local Path">
<el-input
v-model="form.pluginConfig.localPath"
placeholder="/path/to/files"
/>
<el-input v-model="form.pluginConfig.localPath" placeholder="/path/to/files" />
</el-form-item>
<el-form-item label="Strip Prefix">
<el-input v-model="form.pluginConfig.stripPrefix" />
@@ -248,20 +188,13 @@
<el-input v-model="form.pluginConfig.httpUser" />
</el-form-item>
<el-form-item label="HTTP Password">
<el-input
v-model="form.pluginConfig.httpPassword"
type="password"
show-password
/>
<el-input v-model="form.pluginConfig.httpPassword" type="password" show-password />
</el-form-item>
</div>
</template>
<template v-if="form.pluginType === 'unix_domain_socket'">
<el-form-item label="Unix Socket Path">
<el-input
v-model="form.pluginConfig.unixPath"
placeholder="/tmp/socket.sock"
/>
<el-input v-model="form.pluginConfig.unixPath" placeholder="/tmp/socket.sock" />
</el-form-item>
</template>
</template>
@@ -270,9 +203,7 @@
<!-- Remote Configuration -->
<div
v-if="
['tcp', 'udp', 'http', 'https', 'tcpmux'].includes(form.type)
"
v-if="['tcp', 'udp', 'http', 'https', 'tcpmux'].includes(form.type)"
id="section-remote"
class="form-card"
>
@@ -292,23 +223,15 @@
<div class="form-tip">Use 0 for random port assignment</div>
</el-form-item>
</template>
<template
v-if="['http', 'https', 'tcpmux'].includes(form.type)"
>
<template v-if="['http', 'https', 'tcpmux'].includes(form.type)">
<el-form-item label="Custom Domains" prop="customDomains">
<el-input
v-model="form.customDomains"
placeholder="example.com, www.example.com"
/>
<el-input v-model="form.customDomains" placeholder="example.com, www.example.com" />
<div class="form-tip">Comma-separated list of domains</div>
</el-form-item>
<el-form-item v-if="form.type !== 'tcpmux'" label="Subdomain">
<el-input v-model="form.subdomain" placeholder="test" />
</el-form-item>
<el-form-item
v-if="form.type === 'tcpmux'"
label="Multiplexer"
>
<el-form-item v-if="form.type === 'tcpmux'" label="Multiplexer">
<el-select v-model="form.multiplexer" class="full-width">
<el-option label="HTTP CONNECT" value="httpconnect" />
</el-select>
@@ -319,9 +242,7 @@
<!-- Authentication -->
<div
v-if="
['http', 'tcpmux', 'stcp', 'sudp', 'xtcp'].includes(form.type)
"
v-if="['http', 'tcpmux', 'stcp', 'sudp', 'xtcp'].includes(form.type)"
id="section-auth"
class="form-card"
>
@@ -335,11 +256,7 @@
<el-input v-model="form.httpUser" />
</el-form-item>
<el-form-item label="HTTP Password">
<el-input
v-model="form.httpPassword"
type="password"
show-password
/>
<el-input v-model="form.httpPassword" type="password" show-password />
</el-form-item>
</div>
<el-form-item label="Route By HTTP User">
@@ -348,20 +265,11 @@
</template>
<template v-if="['stcp', 'sudp', 'xtcp'].includes(form.type)">
<el-form-item label="Secret Key" prop="secretKey">
<el-input
v-model="form.secretKey"
type="password"
show-password
/>
<el-input v-model="form.secretKey" type="password" show-password />
</el-form-item>
<el-form-item label="Allow Users">
<el-input
v-model="form.allowUsers"
placeholder="user1, user2"
/>
<div class="form-tip">
Comma-separated list of allowed users
</div>
<el-input v-model="form.allowUsers" placeholder="user1, user2" />
<div class="form-tip">Comma-separated list of allowed users</div>
</el-form-item>
</template>
</div>
@@ -373,42 +281,24 @@
id="section-http"
class="form-card collapsible-card"
>
<div
class="card-header clickable"
@click="sections.httpOptions = !sections.httpOptions"
>
<div class="card-header clickable" @click="sections.httpOptions = !sections.httpOptions">
<h3 class="card-title">HTTP Options</h3>
<el-icon
class="collapse-icon"
:class="{ expanded: sections.httpOptions }"
><ArrowDown
/></el-icon>
<el-icon class="collapse-icon" :class="{ expanded: sections.httpOptions }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="sections.httpOptions" class="card-body">
<el-form-item label="Locations">
<el-input
v-model="form.locations"
placeholder="/path1, /path2"
/>
<el-input v-model="form.locations" placeholder="/path1, /path2" />
<div class="form-tip">Comma-separated URL paths</div>
</el-form-item>
<el-form-item label="Host Header Rewrite">
<el-input v-model="form.hostHeaderRewrite" />
</el-form-item>
<el-form-item label="Request Headers">
<KeyValueEditor
v-model="form.requestHeaders"
key-placeholder="Header"
value-placeholder="Value"
/>
<KeyValueEditor v-model="form.requestHeaders" key-placeholder="Header" value-placeholder="Value" />
</el-form-item>
<el-form-item label="Response Headers">
<KeyValueEditor
v-model="form.responseHeaders"
key-placeholder="Header"
value-placeholder="Value"
/>
<KeyValueEditor v-model="form.responseHeaders" key-placeholder="Header" value-placeholder="Value" />
</el-form-item>
</div>
</el-collapse-transition>
@@ -416,16 +306,9 @@
<!-- Transport -->
<div id="section-transport" class="form-card collapsible-card">
<div
class="card-header clickable"
@click="sections.transport = !sections.transport"
>
<div class="card-header clickable" @click="sections.transport = !sections.transport">
<h3 class="card-title">Transport</h3>
<el-icon
class="collapse-icon"
:class="{ expanded: sections.transport }"
><ArrowDown
/></el-icon>
<el-icon class="collapse-icon" :class="{ expanded: sections.transport }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="sections.transport" class="card-body">
@@ -439,27 +322,18 @@
</div>
<div class="field-row two-col">
<el-form-item label="Bandwidth Limit">
<el-input
v-model="form.bandwidthLimit"
placeholder="1MB"
/>
<el-input v-model="form.bandwidthLimit" placeholder="1MB" />
<div class="form-tip">e.g., 1MB, 500KB</div>
</el-form-item>
<el-form-item label="Bandwidth Limit Mode">
<el-select
v-model="form.bandwidthLimitMode"
class="full-width"
>
<el-select v-model="form.bandwidthLimitMode" class="full-width">
<el-option label="Client" value="client" />
<el-option label="Server" value="server" />
</el-select>
</el-form-item>
</div>
<el-form-item label="Proxy Protocol Version">
<el-select
v-model="form.proxyProtocolVersion"
class="full-width"
>
<el-select v-model="form.proxyProtocolVersion" class="full-width">
<el-option label="None" value="" />
<el-option label="v1" value="v1" />
<el-option label="v2" value="v2" />
@@ -471,24 +345,14 @@
<!-- Health Check -->
<div id="section-health" class="form-card collapsible-card">
<div
class="card-header clickable"
@click="sections.healthCheck = !sections.healthCheck"
>
<div class="card-header clickable" @click="sections.healthCheck = !sections.healthCheck">
<h3 class="card-title">Health Check</h3>
<el-icon
class="collapse-icon"
:class="{ expanded: sections.healthCheck }"
><ArrowDown
/></el-icon>
<el-icon class="collapse-icon" :class="{ expanded: sections.healthCheck }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="sections.healthCheck" class="card-body">
<el-form-item label="Type">
<el-select
v-model="form.healthCheckType"
class="full-width"
>
<el-select v-model="form.healthCheckType" class="full-width">
<el-option label="Disabled" value="" />
<el-option label="TCP" value="tcp" />
<el-option label="HTTP" value="http" />
@@ -497,43 +361,21 @@
<template v-if="form.healthCheckType">
<div class="field-row three-col">
<el-form-item label="Timeout (s)">
<el-input-number
v-model="form.healthCheckTimeoutSeconds"
:min="1"
controls-position="right"
class="full-width"
/>
<el-input-number v-model="form.healthCheckTimeoutSeconds" :min="1" controls-position="right" class="full-width" />
</el-form-item>
<el-form-item label="Max Failed">
<el-input-number
v-model="form.healthCheckMaxFailed"
:min="1"
controls-position="right"
class="full-width"
/>
<el-input-number v-model="form.healthCheckMaxFailed" :min="1" controls-position="right" class="full-width" />
</el-form-item>
<el-form-item label="Interval (s)">
<el-input-number
v-model="form.healthCheckIntervalSeconds"
:min="1"
controls-position="right"
class="full-width"
/>
<el-input-number v-model="form.healthCheckIntervalSeconds" :min="1" controls-position="right" class="full-width" />
</el-form-item>
</div>
<template v-if="form.healthCheckType === 'http'">
<el-form-item label="Path" prop="healthCheckPath">
<el-input
v-model="form.healthCheckPath"
placeholder="/health"
/>
<el-input v-model="form.healthCheckPath" placeholder="/health" />
</el-form-item>
<el-form-item label="HTTP Headers">
<KeyValueEditor
v-model="healthCheckHeaders"
key-placeholder="Header"
value-placeholder="Value"
/>
<KeyValueEditor v-model="healthCheckHeaders" key-placeholder="Header" value-placeholder="Value" />
</el-form-item>
</template>
</template>
@@ -543,25 +385,15 @@
<!-- Load Balancer -->
<div id="section-lb" class="form-card collapsible-card">
<div
class="card-header clickable"
@click="sections.loadBalancer = !sections.loadBalancer"
>
<div class="card-header clickable" @click="sections.loadBalancer = !sections.loadBalancer">
<h3 class="card-title">Load Balancer</h3>
<el-icon
class="collapse-icon"
:class="{ expanded: sections.loadBalancer }"
><ArrowDown
/></el-icon>
<el-icon class="collapse-icon" :class="{ expanded: sections.loadBalancer }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="sections.loadBalancer" class="card-body">
<div class="field-row two-col">
<el-form-item label="Group">
<el-input
v-model="form.loadBalancerGroup"
placeholder="Group name"
/>
<el-input v-model="form.loadBalancerGroup" placeholder="Group name" />
</el-form-item>
<el-form-item label="Group Key">
<el-input v-model="form.loadBalancerGroupKey" />
@@ -577,26 +409,15 @@
id="section-nat"
class="form-card collapsible-card"
>
<div
class="card-header clickable"
@click="sections.natTraversal = !sections.natTraversal"
>
<div class="card-header clickable" @click="sections.natTraversal = !sections.natTraversal">
<h3 class="card-title">NAT Traversal</h3>
<el-icon
class="collapse-icon"
:class="{ expanded: sections.natTraversal }"
><ArrowDown
/></el-icon>
<el-icon class="collapse-icon" :class="{ expanded: sections.natTraversal }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="sections.natTraversal" 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-switch v-model="form.natTraversalDisableAssistedAddrs" />
<div class="form-tip">Only use STUN-discovered public addresses</div>
</el-form-item>
</div>
</el-collapse-transition>
@@ -604,16 +425,9 @@
<!-- Metadata & Annotations -->
<div id="section-meta" class="form-card collapsible-card">
<div
class="card-header clickable"
@click="sections.metadata = !sections.metadata"
>
<div class="card-header clickable" @click="sections.metadata = !sections.metadata">
<h3 class="card-title">Metadata & Annotations</h3>
<el-icon
class="collapse-icon"
:class="{ expanded: sections.metadata }"
><ArrowDown
/></el-icon>
<el-icon class="collapse-icon" :class="{ expanded: sections.metadata }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="sections.metadata" class="card-body">
@@ -644,7 +458,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
@@ -656,7 +470,11 @@ import {
formToStoreProxy,
storeProxyToForm,
} from '../types/proxy'
import { getStoreProxy, createStoreProxy, updateStoreProxy } from '../api/frpc'
import {
getStoreProxy,
createStoreProxy,
updateStoreProxy,
} from '../api/frpc'
import KeyValueEditor from '../components/KeyValueEditor.vue'
const route = useRoute()
@@ -669,7 +487,6 @@ const formRef = ref<FormInstance>()
const form = ref<ProxyFormData>(createDefaultProxyForm())
const backendMode = ref<'direct' | 'plugin'>('direct')
const activeSection = ref('section-basic')
const isHydrating = ref(false)
const PLUGIN_LIST = [
'http2https',
@@ -708,10 +525,7 @@ const pluginRequestHeaders = computed({
get() {
const set = form.value.pluginConfig?.requestHeaders?.set
if (!set || typeof set !== 'object') return []
return Object.entries(set).map(([key, value]) => ({
key,
value: String(value),
}))
return Object.entries(set).map(([key, value]) => ({ key, value: String(value) }))
},
set(val: Array<{ key: string; value: string }>) {
if (!form.value.pluginConfig) form.value.pluginConfig = {}
@@ -728,32 +542,18 @@ const pluginRequestHeaders = computed({
// Health check HTTP headers adapter ({ name, value } <-> { key, value })
const healthCheckHeaders = computed({
get() {
return form.value.healthCheckHTTPHeaders.map((h) => ({
key: h.name,
value: h.value,
}))
return form.value.healthCheckHTTPHeaders.map((h) => ({ key: h.name, value: h.value }))
},
set(val: Array<{ key: string; value: string }>) {
form.value.healthCheckHTTPHeaders = val.map((h) => ({
name: h.key,
value: h.value,
}))
form.value.healthCheckHTTPHeaders = val.map((h) => ({ name: h.key, value: h.value }))
},
})
const allSections = [
{ id: 'section-basic', label: 'Basic', always: true },
{ id: 'section-backend', label: 'Backend', always: true },
{
id: 'section-remote',
label: 'Remote',
types: ['tcp', 'udp', 'http', 'https', 'tcpmux'],
},
{
id: 'section-auth',
label: 'Auth',
types: ['http', 'tcpmux', 'stcp', 'sudp', 'xtcp'],
},
{ id: 'section-remote', label: 'Remote', types: ['tcp', 'udp', 'http', 'https', 'tcpmux'] },
{ id: 'section-auth', label: 'Auth', types: ['http', 'tcpmux', 'stcp', 'sudp', 'xtcp'] },
{ id: 'section-http', label: 'HTTP', types: ['http'] },
{ id: 'section-transport', label: 'Transport', always: true },
{ id: 'section-health', label: 'Health', always: true },
@@ -832,14 +632,13 @@ const goBack = () => {
watch(
() => form.value.pluginType,
(newType, oldType) => {
if (isHydrating.value) return
// Only reset when switching between two plugin types.
if (!oldType || !newType || newType === oldType) return
if (
form.value.pluginConfig &&
Object.keys(form.value.pluginConfig).length > 0
) {
form.value.pluginConfig = {}
if (newType !== oldType) {
// Preserve type, reset the rest
const preserved = form.value.pluginConfig
if (preserved && Object.keys(preserved).length > 0) {
// Only reset if type actually changed from a different plugin
form.value.pluginConfig = {}
}
}
},
)
@@ -859,17 +658,16 @@ const loadProxy = async () => {
if (!name) return
pageLoading.value = true
isHydrating.value = true
try {
const res = await getStoreProxy(name)
form.value = storeProxyToForm(res)
backendMode.value = form.value.pluginType ? 'plugin' : 'direct'
await nextTick()
if (form.value.pluginType) {
backendMode.value = 'plugin'
}
} catch (err: any) {
ElMessage.error('Failed to load proxy: ' + err.message)
router.push('/')
} finally {
isHydrating.value = false
pageLoading.value = false
}
}
@@ -907,19 +705,6 @@ onMounted(() => {
loadProxy()
}
})
watch(
() => route.params.name,
(name, oldName) => {
if (name === oldName) return
if (name) {
loadProxy()
return
}
form.value = createDefaultProxyForm()
backendMode.value = 'direct'
},
)
</script>
<style scoped>
@@ -1139,32 +924,14 @@ html.dark .collapsible-card .card-body {
color: var(--el-text-color-secondary);
}
.type-tag-inline.type-tcp {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.type-tag-inline.type-udp {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.type-tag-inline.type-http {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.type-tag-inline.type-https {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
.type-tag-inline.type-tcp { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.type-tag-inline.type-udp { background: rgba(245, 158, 11, 0.1); color: #f59e0b; }
.type-tag-inline.type-http { background: rgba(16, 185, 129, 0.1); color: #10b981; }
.type-tag-inline.type-https { background: rgba(16, 185, 129, 0.15); color: #059669; }
.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-tag-inline.type-tcpmux {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
.type-tag-inline.type-xtcp { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; }
.type-tag-inline.type-tcpmux { background: rgba(236, 72, 153, 0.1); color: #ec4899; }
.type-desc {
font-size: 12px;

View File

@@ -7,9 +7,7 @@
</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>
<span class="breadcrumb-current">{{ isEditing ? 'Edit Visitor' : 'Create Visitor' }}</span>
</nav>
<div v-loading="pageLoading" class="edit-content">
@@ -74,25 +72,14 @@
<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-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-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-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">
@@ -101,7 +88,7 @@
<el-form-item label="Bind Port" prop="bindPort">
<el-input-number
v-model="form.bindPort"
:min="bindPortMin"
:min="1"
:max="65535"
controls-position="right"
class="full-width"
@@ -113,16 +100,9 @@
<!-- Transport Options (collapsible) -->
<div class="form-card collapsible-card">
<div
class="card-header clickable"
@click="transportExpanded = !transportExpanded"
>
<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>
<el-icon class="collapse-icon" :class="{ expanded: transportExpanded }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="transportExpanded" class="card-body">
@@ -141,14 +121,9 @@
<!-- 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"
>
<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>
<el-icon class="collapse-icon" :class="{ expanded: xtcpExpanded }"><ArrowDown /></el-icon>
</div>
<el-collapse-transition>
<div v-show="xtcpExpanded" class="card-body">
@@ -163,36 +138,18 @@
</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-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-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-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-input-number v-model="form.fallbackTimeoutMs" :min="0" controls-position="right" class="full-width" />
</el-form-item>
</div>
</div>
@@ -201,22 +158,15 @@
<!-- NAT Traversal (collapsible, xtcp only) -->
<div class="form-card collapsible-card">
<div
class="card-header clickable"
@click="natExpanded = !natExpanded"
>
<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>
<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>
<div class="form-tip">Only use STUN-discovered public addresses</div>
</el-form-item>
</div>
</el-collapse-transition>
@@ -238,7 +188,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
@@ -267,7 +217,6 @@ 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: [
@@ -280,32 +229,7 @@ const formRules: FormRules = {
],
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',
},
{ type: 'number', min: 1, message: 'Port must be greater than 0', trigger: 'blur' },
],
}
@@ -362,18 +286,6 @@ onMounted(() => {
loadVisitor()
}
})
watch(
() => route.params.name,
(name, oldName) => {
if (name === oldName) return
if (name) {
loadVisitor()
return
}
form.value = createDefaultVisitorForm()
},
)
</script>
<style scoped>

30
web/frps/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,30 @@
/* 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'],
},
],
},
}

View File

@@ -1,5 +1,3 @@
//go:build !noweb
package frps
import (

View File

@@ -1,3 +0,0 @@
//go:build noweb
package frps

View File

@@ -1,36 +0,0 @@
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,
]

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,13 @@
"name": "frps-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 --fix"
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"element-plus": "^2.13.0",
@@ -17,13 +16,14 @@
"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": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
"eslint": "^9.39.0",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.33.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.7.4",

View File

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

View File

@@ -86,7 +86,7 @@ const processData = (trafficIn: number[], trafficOut: number[]) => {
// Calculate dates (last 7 days ending today)
const dates: string[] = []
const d = new Date()
let d = new Date()
d.setDate(d.getDate() - 6)
for (let i = 0; i < 7; i++) {

View File

@@ -24,9 +24,7 @@
<div class="client-info">
<div class="client-name-row">
<h1 class="client-name">{{ client.displayName }}</h1>
<el-tag v-if="client.version" size="small" type="success"
>v{{ client.version }}</el-tag
>
<el-tag v-if="client.version" size="small" type="success">v{{ client.version }}</el-tag>
</div>
<div class="client-meta">
<span v-if="client.ip" class="meta-item">{{

View File

@@ -230,7 +230,7 @@ const fetchData = async () => {
data.value.proxyCounts += count || 0
})
}
} catch {
} catch (err) {
ElMessage({
showClose: true,
message: 'Get server info from frps failed!',