Compare commits

..

17 Commits

Author SHA1 Message Date
fatedier
381245a439 build: add noweb tag to allow building without frontend assets (#5189) 2026-03-02 01:32:19 +08:00
fatedier
01997deb98 add persistent proxy/visitor store with CRUD API and web UI (#5188) 2026-03-02 01:09:59 +08:00
fatedier
d0347325fc pkg/config: fix custom domain validation to prevent false matches with subdomain host (#5178) 2026-02-13 14:10:18 +08:00
fatedier
519368b1fd server/api: fix DeleteProxies endpoint returning empty response instead of JSON (#5163) 2026-02-06 11:22:34 +08:00
fatedier
9634fd99d1 web: ensure npm install runs before build (#5154) 2026-02-04 12:55:24 +08:00
fatedier
7a1c248b67 bump version to 0.67.0 (#5146) 2026-01-31 13:49:29 +08:00
fatedier
886c9c2fdb web/frpc: redesign dashboard (#5145) 2026-01-31 12:43:31 +08:00
fatedier
266c492b5d web/frps: add detailed client and proxy views with enhanced tracking (#5144) 2026-01-31 02:18:35 +08:00
fatedier
5dd70ace6b refactor: move web embeds to web/ directory (#5139) 2026-01-27 02:56:57 +08:00
fatedier
fb2c98e87b rotate gold sponsor 2026-01-27 02:45:57 +08:00
fatedier
ed13141c56 refactor: separate API handlers into dedicated packages with improved HTTP utilities (#5127) 2026-01-14 19:50:55 +08:00
fatedier
3370bd53f5 udp: fix proxy protocol header sent on every packet instead of first packet only (#5119) 2026-01-09 11:33:00 +08:00
fatedier
1245f8804e server: replace client metadata with IP address in registry (#5118) 2026-01-09 11:07:19 +08:00
fatedier
479e9f50c2 web/frpc: refactor dashboard with improved structure and API layer (#5117) 2026-01-09 00:40:51 +08:00
fatedier
a4175a2595 update release notes (#5116) 2026-01-08 20:25:03 +08:00
fatedier
36718d88e4 server: add client registry with dashboard support (#5115) 2026-01-08 20:07:14 +08:00
fatedier
bc378bcbec rotate gold sponsor order periodically (#5114) 2026-01-08 19:54:13 +08:00
178 changed files with 30860 additions and 7901 deletions

View File

@@ -2,10 +2,18 @@ version: 2
jobs:
go-version-latest:
docker:
- image: cimg/go:1.24-node
- image: cimg/go:1.25-node
resource_class: large
steps:
- checkout
- run:
name: Build web assets (frps)
command: make install build
working_directory: web/frps
- run:
name: Build web assets (frpc)
command: make install build
working_directory: web/frpc
- run: make
- run: make alltest

View File

@@ -17,10 +17,19 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25'
cache: false
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build web assets (frps)
run: make build
working-directory: web/frps
- name: Build web assets (frpc)
run: make build
working-directory: web/frpc
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v9
with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
version: v2.3
version: v2.10

View File

@@ -15,14 +15,22 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25'
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build web assets (frps)
run: make build
working-directory: web/frps
- name: Build web assets (frpc)
run: make build
working-directory: web/frpc
- name: Make All
run: |
./package.sh
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean --release-notes=./Release.md

14
.gitignore vendored
View File

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

View File

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

View File

@@ -1,20 +1,24 @@
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')
all: env fmt build
.PHONY: web frps-web frpc-web frps frpc
all: env fmt web build
build: frps frpc
env:
@go version
# compile assets into binary file
file:
rm -rf ./assets/frps/static/*
rm -rf ./assets/frpc/static/*
cp -rf ./web/frps/dist/* ./assets/frps/static
cp -rf ./web/frpc/dist/* ./assets/frpc/static
web: frps-web frpc-web
frps-web:
$(MAKE) -C web/frps build
frpc-web:
$(MAKE) -C web/frpc build
fmt:
go fmt ./...
@@ -26,22 +30,22 @@ gci:
gci write -s standard -s default -s "prefix(github.com/fatedier/frp/)" ./
vet:
go vet ./...
go vet -tags "$(NOWEB_TAG)" ./...
frps:
env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags frps -o bin/frps ./cmd/frps
env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags "frps$(NOWEB_TAG)" -o bin/frps ./cmd/frps
frpc:
env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags frpc -o bin/frpc ./cmd/frpc
env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags "frpc$(NOWEB_TAG)" -o bin/frpc ./cmd/frpc
test: gotest
gotest:
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/...
go test -tags "$(NOWEB_TAG)" -v --cover ./assets/...
go test -tags "$(NOWEB_TAG)" -v --cover ./cmd/...
go test -tags "$(NOWEB_TAG)" -v --cover ./client/...
go test -tags "$(NOWEB_TAG)" -v --cover ./server/...
go test -tags "$(NOWEB_TAG)" -v --cover ./pkg/...
e2e:
./hack/run-e2e.sh

View File

@@ -13,6 +13,26 @@ 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">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -30,34 +50,6 @@ frp is an open source project with its ongoing development made possible entirel
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<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">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://go.warp.dev/frp" target="_blank">
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
<br>
<b>Warp, built for collaborating with AI Agents</b>
<br>
<sub>Available for macOS, Linux and Windows</sub>
</a>
</p>
<!--gold sponsors end-->
## What is frp?
@@ -809,6 +801,14 @@ Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to l
**Note that global client parameters won't be modified except 'start'.**
`start` is a global allowlist evaluated after all sources are merged (config file/include/store).
If `start` is non-empty, any proxy or visitor not listed there will not be started, including
entries created via Store API.
`start` is kept mainly for compatibility and is generally not recommended for new configurations.
Prefer per-proxy/per-visitor `enabled`, and keep `start` empty unless you explicitly want this
global allowlist behavior.
You can run command `frpc verify -c ./frpc.toml` before reloading to check if there are config errors.
### Get proxy status from client

View File

@@ -15,6 +15,26 @@ 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">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -32,33 +52,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<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">
<br>
<b>Requestly - Free & Open-Source alternative to Postman</b>
<br>
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
</a>
</p>
<p align="center">
<a href="https://go.warp.dev/frp" target="_blank">
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
<br>
<b>Warp, built for collaborating with AI Agents</b>
<br>
<sub>Available for macOS, Linux and Windows</sub>
</a>
</p>
<!--gold sponsors end-->
## 为什么使用 frp

View File

@@ -1,13 +1,8 @@
## Features
* HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities.
* Individual frpc proxies and visitors now accept an `enabled` flag (defaults to true), letting you disable specific entries without relying on the global `start` list—disabled blocks are skipped when client configs load.
* OIDC authentication now supports a `tokenSource` field to dynamically obtain tokens from external sources. You can use `type = "file"` to read a token from a file, or `type = "exec"` to run an external command (e.g., a cloud CLI or secrets manager) and capture its stdout as the token. The `exec` type requires the `--allow-unsafe=TokenSourceExec` CLI flag for security reasons.
* 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.
## Improvements
* **VirtualNet**: Implemented intelligent reconnection with exponential backoff. When connection errors occur repeatedly, the reconnect interval increases from 60s to 300s (max), reducing unnecessary reconnection attempts. Normal disconnections still reconnect quickly at 10s intervals.
## Fixes
* Fix deadlock issue when TCP connection is closed. Previously, sending messages could block forever if the connection handler had already stopped.
* 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.

View File

@@ -29,19 +29,28 @@ var (
prefixPath string
)
type emptyFS struct{}
func (emptyFS) Open(name string) (http.File, error) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
// if path is empty, load assets in memory
// or set FileSystem using disk files
func Load(path string) {
prefixPath = path
if prefixPath != "" {
switch {
case prefixPath != "":
FileSystem = http.Dir(prefixPath)
} else {
case content != nil:
FileSystem = http.FS(content)
default:
FileSystem = emptyFS{}
}
}
func Register(fileSystem fs.FS) {
subFs, err := fs.Sub(fileSystem, "static")
subFs, err := fs.Sub(fileSystem, "dist")
if err == nil {
content = subFs
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>frp client admin UI</title>
<script type="module" crossorigin src="./index-bLBhaJo8.js"></script>
<link rel="stylesheet" crossorigin href="./index-iuf46MlF.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -1,14 +0,0 @@
package frpc
import (
"embed"
"github.com/fatedier/frp/assets"
)
//go:embed static/*
var content embed.FS
func init() {
assets.Register(content)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8">
<title>frps dashboard</title>
<script type="module" crossorigin src="./index-82-40HIG.js"></script>
<link rel="stylesheet" crossorigin href="./index-rzPDshRD.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -15,44 +15,43 @@
package client
import (
"cmp"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"slices"
"strconv"
"time"
"github.com/fatedier/frp/client/api"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/v1/validation"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
type GeneralResponse struct {
Code int
Msg string
}
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
helper.Router.HandleFunc("/healthz", svr.healthz)
apiController := newAPIController(svr)
// Healthz endpoint without auth
helper.Router.HandleFunc("/healthz", healthz)
// API routes and static files with auth
subRouter := helper.Router.NewRoute().Subrouter()
subRouter.Use(helper.AuthMiddleware)
subRouter.Use(httppkg.NewRequestLogger)
subRouter.HandleFunc("/api/reload", httppkg.MakeHTTPHandlerFunc(apiController.Reload)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/stop", httppkg.MakeHTTPHandlerFunc(apiController.Stop)).Methods(http.MethodPost)
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
subRouter.Use(helper.AuthMiddleware.Middleware)
if svr.storeSource != nil {
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreProxy)).Methods(http.MethodPost)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreProxy)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreProxy)).Methods(http.MethodPut)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreProxy)).Methods(http.MethodDelete)
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreVisitors)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreVisitor)).Methods(http.MethodPost)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreVisitor)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreVisitor)).Methods(http.MethodPut)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreVisitor)).Methods(http.MethodDelete)
}
// api, see admin_api.go
subRouter.HandleFunc("/api/reload", svr.apiReload).Methods("GET")
subRouter.HandleFunc("/api/stop", svr.apiStop).Methods("POST")
subRouter.HandleFunc("/api/status", svr.apiStatus).Methods("GET")
subRouter.HandleFunc("/api/config", svr.apiGetConfig).Methods("GET")
subRouter.HandleFunc("/api/config", svr.apiPutConfig).Methods("PUT")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
@@ -62,201 +61,25 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
})
}
// /healthz
func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
func healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
// GET /api/reload
func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
strictConfigMode := false
strictStr := r.URL.Query().Get("strictConfig")
if strictStr != "" {
strictConfigMode, _ = strconv.ParseBool(strictStr)
}
log.Infof("api request [/api/reload]")
defer func() {
log.Infof("api response [/api/reload], code [%d]", res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.configFilePath, strictConfigMode)
if err != nil {
res.Code = 400
res.Msg = err.Error()
log.Warnf("reload frpc proxy config error: %s", res.Msg)
return
}
if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, svr.unsafeFeatures); err != nil {
res.Code = 400
res.Msg = err.Error()
log.Warnf("reload frpc proxy config error: %s", res.Msg)
return
}
if err := svr.UpdateAllConfigurer(proxyCfgs, visitorCfgs); err != nil {
res.Code = 500
res.Msg = err.Error()
log.Warnf("reload frpc proxy config error: %s", res.Msg)
return
}
log.Infof("success reload conf")
func newAPIController(svr *Service) *api.Controller {
manager := newServiceConfigManager(svr)
return api.NewController(api.ControllerParams{
ServerAddr: svr.common.ServerAddr,
Manager: manager,
})
}
// POST /api/stop
func (svr *Service) apiStop(w http.ResponseWriter, _ *http.Request) {
res := GeneralResponse{Code: 200}
log.Infof("api request [/api/stop]")
defer func() {
log.Infof("api response [/api/stop], code [%d]", res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
go svr.GracefulClose(100 * time.Millisecond)
}
type StatusResp map[string][]ProxyStatusResp
type ProxyStatusResp struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Err string `json:"err"`
LocalAddr string `json:"local_addr"`
Plugin string `json:"plugin"`
RemoteAddr string `json:"remote_addr"`
}
func NewProxyStatusResp(status *proxy.WorkingStatus, serverAddr string) 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 = serverAddr + psr.RemoteAddr
}
}
return psr
}
// GET /api/status
func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) {
var (
buf []byte
res StatusResp = make(map[string][]ProxyStatusResp)
)
log.Infof("http request [/api/status]")
defer func() {
log.Infof("http response [/api/status]")
buf, _ = json.Marshal(&res)
_, _ = w.Write(buf)
}()
// getAllProxyStatus returns all proxy statuses.
func (svr *Service) getAllProxyStatus() []*proxy.WorkingStatus {
svr.ctlMu.RLock()
ctl := svr.ctl
svr.ctlMu.RUnlock()
if ctl == nil {
return
}
ps := ctl.pm.GetAllProxyStatus()
for _, status := range ps {
res[status.Type] = append(res[status.Type], NewProxyStatusResp(status, svr.common.ServerAddr))
}
for _, arrs := range res {
if len(arrs) <= 1 {
continue
}
slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
return cmp.Compare(a.Name, b.Name)
})
}
}
// GET /api/config
func (svr *Service) apiGetConfig(w http.ResponseWriter, _ *http.Request) {
res := GeneralResponse{Code: 200}
log.Infof("http get request [/api/config]")
defer func() {
log.Infof("http get response [/api/config], code [%d]", res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
if svr.configFilePath == "" {
res.Code = 400
res.Msg = "frpc has no config file path"
log.Warnf("%s", res.Msg)
return
}
content, err := os.ReadFile(svr.configFilePath)
if err != nil {
res.Code = 400
res.Msg = err.Error()
log.Warnf("load frpc config file error: %s", res.Msg)
return
}
res.Msg = string(content)
}
// PUT /api/config
func (svr *Service) apiPutConfig(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
log.Infof("http put request [/api/config]")
defer func() {
log.Infof("http put response [/api/config], code [%d]", res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
// get new config content
body, err := io.ReadAll(r.Body)
if err != nil {
res.Code = 400
res.Msg = fmt.Sprintf("read request body error: %v", err)
log.Warnf("%s", res.Msg)
return
}
if len(body) == 0 {
res.Code = 400
res.Msg = "body can't be empty"
log.Warnf("%s", res.Msg)
return
}
if err := os.WriteFile(svr.configFilePath, body, 0o600); err != nil {
res.Code = 500
res.Msg = fmt.Sprintf("write content to frpc config file error: %v", err)
log.Warnf("%s", res.Msg)
return
return nil
}
return ctl.pm.GetAllProxyStatus()
}

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

@@ -0,0 +1,390 @@
// 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"
"errors"
"fmt"
"net"
"net/http"
"slices"
"strconv"
"time"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/client/proxy"
v1 "github.com/fatedier/frp/pkg/config/v1"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
// 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())
}
// TODO(fatedier): Remove this lock wrapper after migrating typed config
// decoding to encoding/json/v2 with per-call options.
// TypedProxyConfig/TypedVisitorConfig currently read global strictness state.
func unmarshalTypedConfig[T any](body []byte, out *T) error {
return v1.WithDisallowUnknownFields(false, func() error {
return json.Unmarshal(body, out)
})
}
// 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(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 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) 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
}
}
if c.manager.IsStoreProxyEnabled(status.Name) {
psr.Source = 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 := ProxyListResp{Proxies: make([]ProxyConfig, 0, len(proxies))}
for _, p := range proxies {
cfg, err := configurerToMap(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, err := c.manager.GetStoreProxy(name)
if err != nil {
return nil, c.toHTTPError(err)
}
cfg, err := configurerToMap(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 := unmarshalTypedConfig(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")
}
if err := c.manager.CreateStoreProxy(typed.ProxyConfigurer); err != nil {
return nil, c.toHTTPError(err)
}
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 := unmarshalTypedConfig(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")
}
if err := c.manager.UpdateStoreProxy(name, typed.ProxyConfigurer); err != nil {
return nil, c.toHTTPError(err)
}
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.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 := VisitorListResp{Visitors: make([]VisitorConfig, 0, len(visitors))}
for _, v := range visitors {
cfg, err := configurerToMap(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, err := c.manager.GetStoreVisitor(name)
if err != nil {
return nil, c.toHTTPError(err)
}
cfg, err := configurerToMap(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 := unmarshalTypedConfig(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")
}
if err := c.manager.CreateStoreVisitor(typed.VisitorConfigurer); err != nil {
return nil, c.toHTTPError(err)
}
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 := unmarshalTypedConfig(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")
}
if err := c.manager.UpdateStoreVisitor(name, typed.VisitorConfigurer); err != nil {
return nil, c.toHTTPError(err)
}
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.manager.DeleteStoreVisitor(name); err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
}
func configurerToMap(v any) (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

@@ -0,0 +1,390 @@
package api
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/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) error
updateStoreProxyFn func(name string, cfg 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) error
updateStoreVisitFn func(name string, cfg 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) error {
if m.createStoreProxyFn != nil {
return m.createStoreProxyFn(cfg)
}
return nil
}
func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error {
if m.updateStoreProxyFn != nil {
return m.updateStoreProxyFn(name, cfg)
}
return 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) error {
if m.createStoreVisitFn != nil {
return m.createStoreVisitFn(cfg)
}
return nil
}
func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error {
if m.updateStoreVisitFn != nil {
return m.updateStoreVisitFn(name, cfg)
}
return 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 setDisallowUnknownFieldsForTest(t *testing.T, value bool) func() {
t.Helper()
v1.DisallowUnknownFieldsMu.Lock()
prev := v1.DisallowUnknownFields
v1.DisallowUnknownFields = value
v1.DisallowUnknownFieldsMu.Unlock()
return func() {
v1.DisallowUnknownFieldsMu.Lock()
v1.DisallowUnknownFields = prev
v1.DisallowUnknownFieldsMu.Unlock()
}
}
func getDisallowUnknownFieldsForTest() bool {
v1.DisallowUnknownFieldsMu.Lock()
defer v1.DisallowUnknownFieldsMu.Unlock()
return v1.DisallowUnknownFields
}
func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
return &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: name,
Type: "tcp",
ProxyBackend: v1.ProxyBackend{
LocalPort: 10080,
},
},
}
}
func newRawXTCPVisitorConfig(name string) *v1.XTCPVisitorConfig {
return &v1.XTCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Name: name,
Type: "xtcp",
ServerName: "server",
BindPort: 10081,
SecretKey: "secret",
},
}
}
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, err := json.Marshal(newRawTCPProxyConfig("shared-proxy"))
if err != nil {
t.Fatalf("marshal body: %v", err)
}
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) error { return tc.err },
},
}
_, err = controller.UpdateStoreProxy(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, tc.expectedCode)
})
}
}
func TestStoreVisitorErrorMapping(t *testing.T) {
body, err := json.Marshal(newRawXTCPVisitorConfig("shared-visitor"))
if err != nil {
t.Fatalf("marshal body: %v", err)
}
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 TestCreateStoreProxy_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) {
restore := setDisallowUnknownFieldsForTest(t, true)
t.Cleanup(restore)
var gotName string
controller := &Controller{
manager: &fakeConfigManager{
createStoreProxyFn: func(cfg v1.ProxyConfigurer) error {
gotName = cfg.GetBaseConfig().Name
return nil
},
},
}
body := []byte(`{"name":"raw-proxy","type":"tcp","localPort":10080,"unexpected":"value"}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, 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)
}
if !getDisallowUnknownFieldsForTest() {
t.Fatal("global strictness flag was not restored")
}
}
func TestCreateStoreVisitor_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) {
restore := setDisallowUnknownFieldsForTest(t, true)
t.Cleanup(restore)
var gotName string
controller := &Controller{
manager: &fakeConfigManager{
createStoreVisitFn: func(cfg v1.VisitorConfigurer) error {
gotName = cfg.GetBaseConfig().Name
return nil
},
},
}
body := []byte(`{"name":"raw-visitor","type":"xtcp","serverName":"server","bindPort":10081,"secretKey":"secret","unexpected":"value"}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, 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)
}
if !getDisallowUnknownFieldsForTest() {
t.Fatal("global strictness flag was not restored")
}
}
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)
}
}

56
client/api/types.go Normal file
View File

@@ -0,0 +1,56 @@
// 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
const SourceStore = "store"
// StatusResp is the response for GET /api/status
type StatusResp map[string][]ProxyStatusResp
// ProxyStatusResp contains proxy status information
type ProxyStatusResp struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Err string `json:"err"`
LocalAddr string `json:"local_addr"`
Plugin string `json:"plugin"`
RemoteAddr string `json:"remote_addr"`
Source string `json:"source,omitempty"` // "store" or "config"
}
// ProxyConfig wraps proxy configuration for API requests/responses.
type ProxyConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
// VisitorConfig wraps visitor configuration for API requests/responses.
type VisitorConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]any `json:"config"`
}
// ProxyListResp is the response for GET /api/store/proxies
type ProxyListResp struct {
Proxies []ProxyConfig `json:"proxies"`
}
// VisitorListResp is the response for GET /api/store/visitors
type VisitorListResp struct {
Visitors []VisitorConfig `json:"visitors"`
}

365
client/config_manager.go Normal file
View File

@@ -0,0 +1,365 @@
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) error {
if err := m.validateStoreProxyConfigurer(cfg); err != nil {
return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.withStoreMutationAndReload(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
}); err != nil {
return err
}
log.Infof("store: created proxy %q", cfg.GetBaseConfig().Name)
return nil
}
func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error {
if name == "" {
return fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
}
if cfg == nil {
return fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument)
}
bodyName := cfg.GetBaseConfig().Name
if bodyName != name {
return fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument)
}
if err := m.validateStoreProxyConfigurer(cfg); err != nil {
return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.withStoreMutationAndReload(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
}); err != nil {
return err
}
log.Infof("store: updated proxy %q", name)
return 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) error {
if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.withStoreMutationAndReload(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
}); err != nil {
return err
}
log.Infof("store: created visitor %q", cfg.GetBaseConfig().Name)
return nil
}
func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error {
if name == "" {
return fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
}
if cfg == nil {
return fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument)
}
bodyName := cfg.GetBaseConfig().Name
if bodyName != name {
return fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument)
}
if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
return fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.withStoreMutationAndReload(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
}); err != nil {
return err
}
log.Infof("store: updated visitor %q", name)
return 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) 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

@@ -0,0 +1,134 @@
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{},
},
}
err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy"))
if err != nil {
t.Fatalf("create store proxy: %v", err)
}
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

@@ -0,0 +1,42 @@
package configmgmt
import (
"errors"
"time"
"github.com/fatedier/frp/client/proxy"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
var (
ErrInvalidArgument = errors.New("invalid argument")
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrStoreDisabled = errors.New("store disabled")
ErrApplyConfig = errors.New("apply config failed")
)
type ConfigManager interface {
ReloadFromFile(strict bool) error
ReadConfigFile() (string, error)
WriteConfigFile(content []byte) error
GetProxyStatus() []*proxy.WorkingStatus
IsStoreProxyEnabled(name string) bool
StoreEnabled() bool
ListStoreProxies() ([]v1.ProxyConfigurer, error)
GetStoreProxy(name string) (v1.ProxyConfigurer, error)
CreateStoreProxy(cfg v1.ProxyConfigurer) error
UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) error
DeleteStoreProxy(name string) error
ListStoreVisitors() ([]v1.VisitorConfigurer, error)
GetStoreVisitor(name string) (v1.VisitorConfigurer, error)
CreateStoreVisitor(cfg v1.VisitorConfigurer) error
UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) error
DeleteStoreVisitor(name string) error
GracefulClose(d time.Duration)
}

View File

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

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

View File

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

View File

@@ -29,6 +29,8 @@ import (
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/auth"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/policy/security"
@@ -61,9 +63,11 @@ func (e cancelErr) Error() string {
// ServiceOptions contains options for creating a new client service.
type ServiceOptions struct {
Common *v1.ClientCommonConfig
ProxyCfgs []v1.ProxyConfigurer
VisitorCfgs []v1.VisitorConfigurer
Common *v1.ClientCommonConfig
// ConfigSourceAggregator manages internal config and optional store sources.
// It is required for creating a Service.
ConfigSourceAggregator *source.Aggregator
UnsafeFeatures *security.UnsafeFeatures
@@ -119,11 +123,23 @@ type Service struct {
vnetController *vnet.Controller
cfgMu sync.RWMutex
common *v1.ClientCommonConfig
proxyCfgs []v1.ProxyConfigurer
visitorCfgs []v1.VisitorConfigurer
clientSpec *msg.ClientSpec
cfgMu sync.RWMutex
// reloadMu serializes reload transactions to keep reloadCommon and applied
// config in sync across concurrent API operations.
reloadMu sync.Mutex
common *v1.ClientCommonConfig
// reloadCommon is used for filtering/defaulting during config-source reloads.
// It can be updated by /api/reload without mutating startup-only common behavior.
reloadCommon *v1.ClientCommonConfig
proxyCfgs []v1.ProxyConfigurer
visitorCfgs []v1.VisitorConfigurer
clientSpec *msg.ClientSpec
// aggregator manages multiple configuration sources.
// When set, the service watches for config changes and reloads automatically.
aggregator *source.Aggregator
configSource *source.ConfigSource
storeSource *source.StoreSource
unsafeFeatures *security.UnsafeFeatures
@@ -160,19 +176,39 @@ func NewService(options ServiceOptions) (*Service, error) {
return nil, err
}
if options.ConfigSourceAggregator == nil {
return nil, fmt.Errorf("config source aggregator is required")
}
configSource := options.ConfigSourceAggregator.ConfigSource()
storeSource := options.ConfigSourceAggregator.StoreSource()
proxyCfgs, visitorCfgs, loadErr := options.ConfigSourceAggregator.Load()
if loadErr != nil {
return nil, fmt.Errorf("failed to load config from aggregator: %w", loadErr)
}
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(options.Common, proxyCfgs, visitorCfgs)
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
s := &Service{
ctx: context.Background(),
auth: authRuntime,
webServer: webServer,
common: options.Common,
reloadCommon: options.Common,
configFilePath: options.ConfigFilePath,
unsafeFeatures: options.UnsafeFeatures,
proxyCfgs: options.ProxyCfgs,
visitorCfgs: options.VisitorCfgs,
proxyCfgs: proxyCfgs,
visitorCfgs: visitorCfgs,
clientSpec: options.ClientSpec,
aggregator: options.ConfigSourceAggregator,
configSource: configSource,
storeSource: storeSource,
connectorCreator: options.ConnectorCreator,
handleWorkConnCb: options.HandleWorkConnCb,
}
if webServer != nil {
webServer.RouteRegister(s.registerRouteHandlers)
}
@@ -281,11 +317,15 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
return
}
hostname, _ := os.Hostname()
loginMsg := &msg.Login{
Arch: runtime.GOARCH,
Os: runtime.GOOS,
Hostname: hostname,
PoolCount: svr.common.Transport.PoolCount,
User: svr.common.User,
ClientID: svr.common.ClientID,
Version: version.Full(),
Timestamp: time.Now().Unix(),
RunID: svr.runID,
@@ -399,6 +439,35 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
return nil
}
func (svr *Service) UpdateConfigSource(
common *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
) error {
svr.reloadMu.Lock()
defer svr.reloadMu.Unlock()
cfgSource := svr.configSource
if cfgSource == nil {
return fmt.Errorf("config source is not available")
}
if err := cfgSource.ReplaceAll(proxyCfgs, visitorCfgs); err != nil {
return err
}
// Non-atomic update semantics: source has been updated at this point.
// Even if reload fails below, keep this common config for subsequent reloads.
svr.cfgMu.Lock()
svr.reloadCommon = common
svr.cfgMu.Unlock()
if err := svr.reloadConfigFromSourcesLocked(); err != nil {
return err
}
return nil
}
func (svr *Service) Close() {
svr.GracefulClose(time.Duration(0))
}
@@ -409,6 +478,15 @@ func (svr *Service) GracefulClose(d time.Duration) {
}
func (svr *Service) stop() {
// Coordinate shutdown with reload/update paths that read source pointers.
svr.reloadMu.Lock()
if svr.aggregator != nil {
svr.aggregator = nil
}
svr.configSource = nil
svr.storeSource = nil
svr.reloadMu.Unlock()
svr.ctlMu.Lock()
defer svr.ctlMu.Unlock()
if svr.ctl != nil {
@@ -449,3 +527,35 @@ type statusExporterImpl struct {
func (s *statusExporterImpl) GetProxyStatus(name string) (*proxy.WorkingStatus, bool) {
return s.getProxyStatusFunc(name)
}
func (svr *Service) reloadConfigFromSources() error {
svr.reloadMu.Lock()
defer svr.reloadMu.Unlock()
return svr.reloadConfigFromSourcesLocked()
}
func (svr *Service) reloadConfigFromSourcesLocked() error {
aggregator := svr.aggregator
if aggregator == nil {
return errors.New("config aggregator is not initialized")
}
svr.cfgMu.RLock()
reloadCommon := svr.reloadCommon
svr.cfgMu.RUnlock()
proxies, visitors, err := aggregator.Load()
if err != nil {
return fmt.Errorf("reload config from sources failed: %w", err)
}
proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)
proxies = config.CompleteProxyConfigurers(proxies)
visitors = config.CompleteVisitorConfigurers(visitors)
// Atomically replace the entire configuration
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
return err
}
return nil
}

140
client/service_test.go Normal file
View File

@@ -0,0 +1,140 @@
package client
import (
"path/filepath"
"strings"
"testing"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestUpdateConfigSourceRollsBackReloadCommonOnReplaceAllFailure(t *testing.T) {
prevCommon := &v1.ClientCommonConfig{User: "old-user"}
newCommon := &v1.ClientCommonConfig{User: "new-user"}
svr := &Service{
configSource: source.NewConfigSource(),
reloadCommon: prevCommon,
}
invalidProxy := &v1.TCPProxyConfig{}
err := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{invalidProxy}, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "proxy name cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
if svr.reloadCommon != prevCommon {
t.Fatalf("reloadCommon should roll back on ReplaceAll failure")
}
}
func TestUpdateConfigSourceKeepsReloadCommonOnReloadFailure(t *testing.T) {
prevCommon := &v1.ClientCommonConfig{User: "old-user"}
newCommon := &v1.ClientCommonConfig{User: "new-user"}
svr := &Service{
// Keep configSource valid so ReplaceAll succeeds first.
configSource: source.NewConfigSource(),
reloadCommon: prevCommon,
// Keep aggregator nil to force reload failure.
aggregator: nil,
}
validProxy := &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "p1",
Type: "tcp",
},
}
err := svr.UpdateConfigSource(newCommon, []v1.ProxyConfigurer{validProxy}, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "config aggregator is not initialized") {
t.Fatalf("unexpected error: %v", err)
}
if svr.reloadCommon != newCommon {
t.Fatalf("reloadCommon should keep new value on reload failure")
}
}
func TestReloadConfigFromSourcesDoesNotMutateStoreConfigs(t *testing.T) {
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
})
if err != nil {
t.Fatalf("new store source: %v", err)
}
proxyCfg := &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "store-proxy",
Type: "tcp",
},
}
visitorCfg := &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Name: "store-visitor",
Type: "stcp",
},
}
if err := storeSource.AddProxy(proxyCfg); err != nil {
t.Fatalf("add proxy to store: %v", err)
}
if err := storeSource.AddVisitor(visitorCfg); err != nil {
t.Fatalf("add visitor to store: %v", err)
}
agg := source.NewAggregator(source.NewConfigSource())
agg.SetStoreSource(storeSource)
svr := &Service{
aggregator: agg,
configSource: agg.ConfigSource(),
storeSource: storeSource,
reloadCommon: &v1.ClientCommonConfig{},
}
if err := svr.reloadConfigFromSources(); err != nil {
t.Fatalf("reload config from sources: %v", err)
}
gotProxy := storeSource.GetProxy("store-proxy")
if gotProxy == nil {
t.Fatalf("proxy not found in store")
}
if gotProxy.GetBaseConfig().LocalIP != "" {
t.Fatalf("store proxy localIP should stay empty, got %q", gotProxy.GetBaseConfig().LocalIP)
}
gotVisitor := storeSource.GetVisitor("store-visitor")
if gotVisitor == nil {
t.Fatalf("visitor not found in store")
}
if gotVisitor.GetBaseConfig().BindAddr != "" {
t.Fatalf("store visitor bindAddr should stay empty, got %q", gotVisitor.GetBaseConfig().BindAddr)
}
svr.cfgMu.RLock()
defer svr.cfgMu.RUnlock()
if len(svr.proxyCfgs) != 1 {
t.Fatalf("expected 1 runtime proxy, got %d", len(svr.proxyCfgs))
}
if svr.proxyCfgs[0].GetBaseConfig().LocalIP != "127.0.0.1" {
t.Fatalf("runtime proxy localIP should be defaulted, got %q", svr.proxyCfgs[0].GetBaseConfig().LocalIP)
}
if len(svr.visitorCfgs) != 1 {
t.Fatalf("expected 1 runtime visitor, got %d", len(svr.visitorCfgs))
}
if svr.visitorCfgs[0].GetBaseConfig().BindAddr != "127.0.0.1" {
t.Fatalf("runtime visitor bindAddr should be defaulted, got %q", svr.visitorCfgs[0].GetBaseConfig().BindAddr)
}
}

View File

@@ -25,6 +25,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/util/util"
"github.com/fatedier/frp/pkg/util/xlog"
)
@@ -103,9 +104,10 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
defer visitorConn.Close()
now := time.Now().Unix()
targetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
newVisitorConnMsg := &msg.NewVisitorConn{
RunID: sv.helper.RunID(),
ProxyName: sv.cfg.ServerName,
ProxyName: targetProxyName,
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
Timestamp: now,
UseEncryption: sv.cfg.Transport.UseEncryption,

View File

@@ -27,6 +27,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/naming"
"github.com/fatedier/frp/pkg/proto/udp"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/util"
@@ -205,9 +206,10 @@ func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) {
}
now := time.Now().Unix()
targetProxyName := naming.BuildTargetServerProxyName(sv.clientCfg.User, sv.cfg.ServerUser, sv.cfg.ServerName)
newVisitorConnMsg := &msg.NewVisitorConn{
RunID: sv.helper.RunID(),
ProxyName: sv.cfg.ServerName,
ProxyName: targetProxyName,
SignKey: util.GetAuthKey(sv.cfg.SecretKey, now),
Timestamp: now,
UseEncryption: sv.cfg.Transport.UseEncryption,

View File

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

View File

@@ -15,9 +15,9 @@
package main
import (
_ "github.com/fatedier/frp/assets/frpc"
"github.com/fatedier/frp/cmd/frpc/sub"
"github.com/fatedier/frp/pkg/util/system"
_ "github.com/fatedier/frp/web/frpc"
)
func main() {

View File

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

View File

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

View File

@@ -15,9 +15,9 @@
package main
import (
_ "github.com/fatedier/frp/assets/frps"
_ "github.com/fatedier/frp/pkg/metrics"
"github.com/fatedier/frp/pkg/util/system"
_ "github.com/fatedier/frp/web/frps"
)
func main() {

View File

@@ -1,5 +1,7 @@
# This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues.
# Optional unique identifier for this frpc instance.
clientID = "your_client_id"
# your proxy name will be changed to {user}.{proxy}
user = "your_name"
@@ -141,6 +143,9 @@ transport.tls.enable = true
# Proxy names you want to start.
# Default is empty, means all proxies.
# This list is a global allowlist after config + store are merged, so entries
# created via Store API are also filtered by this list.
# If start is non-empty, any proxy/visitor not listed here will not be started.
# start = ["ssh", "dns"]
# Alternative to 'start': You can control each proxy individually using the 'enabled' field.

View File

@@ -1,9 +1,17 @@
FROM golang:1.24 AS building
FROM node:22 AS web-builder
WORKDIR /web/frpc
COPY web/frpc/ ./
RUN npm install
RUN npm run build
FROM golang:1.25 AS building
COPY . /building
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
WORKDIR /building
RUN make frpc
RUN env CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -tags frpc -o bin/frpc ./cmd/frpc
FROM alpine:3

View File

@@ -1,9 +1,17 @@
FROM golang:1.24 AS building
FROM node:22 AS web-builder
WORKDIR /web/frps
COPY web/frps/ ./
RUN npm install
RUN npm run build
FROM golang:1.25 AS building
COPY . /building
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
WORKDIR /building
RUN make frps
RUN env CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -tags frps -o bin/frps ./cmd/frps
FROM alpine:3

2
go.mod
View File

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

View File

@@ -167,6 +167,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
}
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
cmd.PersistentFlags().StringVar(&c.ClientID, "client-id", "", "unique identifier for this frpc instance")
cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token")
}

View File

@@ -141,34 +141,33 @@ func parseYAMLWithDotFieldsHandling(content []byte, target any) error {
// LoadConfigure loads configuration from bytes and unmarshal into c.
// Now it supports json, yaml and toml format.
func LoadConfigure(b []byte, c any, strict bool) error {
v1.DisallowUnknownFieldsMu.Lock()
defer v1.DisallowUnknownFieldsMu.Unlock()
v1.DisallowUnknownFields = strict
var tomlObj any
// Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
if err := toml.Unmarshal(b, &tomlObj); err == nil {
b, err = json.Marshal(&tomlObj)
if err != nil {
return err
return v1.WithDisallowUnknownFields(strict, func() error {
var tomlObj any
// Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
if err := toml.Unmarshal(b, &tomlObj); err == nil {
var err error
b, err = json.Marshal(&tomlObj)
if err != nil {
return err
}
}
}
// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
if yaml.IsJSONBuffer(b) {
decoder := json.NewDecoder(bytes.NewBuffer(b))
// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
if yaml.IsJSONBuffer(b) {
decoder := json.NewDecoder(bytes.NewBuffer(b))
if strict {
decoder.DisallowUnknownFields()
}
return decoder.Decode(c)
}
// Handle YAML content
if strict {
decoder.DisallowUnknownFields()
// In strict mode, always use our custom handler to support YAML merge
return parseYAMLWithDotFieldsHandling(b, c)
}
return decoder.Decode(c)
}
// Handle YAML content
if strict {
// In strict mode, always use our custom handler to support YAML merge
return parseYAMLWithDotFieldsHandling(b, c)
}
// Non-strict mode, parse normally
return yaml.Unmarshal(b, c)
// Non-strict mode, parse normally
return yaml.Unmarshal(b, c)
})
}
func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {
@@ -180,7 +179,7 @@ func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.
}
configurer.UnmarshalFromMsg(m)
configurer.Complete("")
configurer.Complete()
if err := validation.ValidateProxyConfigurerForServer(configurer, serverCfg); err != nil {
return nil, err
@@ -219,60 +218,132 @@ func LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error)
return svrCfg, isLegacyFormat, nil
}
// ClientConfigLoadResult contains the result of loading a client configuration file.
type ClientConfigLoadResult struct {
// Common contains the common client configuration.
Common *v1.ClientCommonConfig
// Proxies contains proxy configurations from inline [[proxies]] and includeConfigFiles.
// These are NOT completed (user prefix not added).
Proxies []v1.ProxyConfigurer
// Visitors contains visitor configurations from inline [[visitors]] and includeConfigFiles.
// These are NOT completed.
Visitors []v1.VisitorConfigurer
// IsLegacyFormat indicates whether the config file is in legacy INI format.
IsLegacyFormat bool
}
// LoadClientConfigResult loads and parses a client configuration file.
// It returns the raw configuration without completing proxies/visitors.
// The caller should call Complete on the configs manually for legacy behavior.
func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult, error) {
result := &ClientConfigLoadResult{
Proxies: make([]v1.ProxyConfigurer, 0),
Visitors: make([]v1.VisitorConfigurer, 0),
}
if DetectLegacyINIFormatFromFile(path) {
legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)
if err != nil {
return nil, err
}
result.Common = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
for _, c := range legacyProxyCfgs {
result.Proxies = append(result.Proxies, legacy.Convert_ProxyConf_To_v1(c))
}
for _, c := range legacyVisitorCfgs {
result.Visitors = append(result.Visitors, legacy.Convert_VisitorConf_To_v1(c))
}
result.IsLegacyFormat = true
} else {
allCfg := v1.ClientConfig{}
if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {
return nil, err
}
result.Common = &allCfg.ClientCommonConfig
for _, c := range allCfg.Proxies {
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
}
for _, c := range allCfg.Visitors {
result.Visitors = append(result.Visitors, c.VisitorConfigurer)
}
}
// Load additional config from includes.
// legacy ini format already handle this in ParseClientConfig.
if len(result.Common.IncludeConfigFiles) > 0 && !result.IsLegacyFormat {
extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(result.Common.IncludeConfigFiles, result.IsLegacyFormat, strict)
if err != nil {
return nil, err
}
result.Proxies = append(result.Proxies, extProxyCfgs...)
result.Visitors = append(result.Visitors, extVisitorCfgs...)
}
// Complete the common config
if result.Common != nil {
if err := result.Common.Complete(); err != nil {
return nil, err
}
}
return result, nil
}
func LoadClientConfig(path string, strict bool) (
*v1.ClientCommonConfig,
[]v1.ProxyConfigurer,
[]v1.VisitorConfigurer,
bool, error,
) {
var (
cliCfg *v1.ClientCommonConfig
proxyCfgs = make([]v1.ProxyConfigurer, 0)
visitorCfgs = make([]v1.VisitorConfigurer, 0)
isLegacyFormat bool
)
if DetectLegacyINIFormatFromFile(path) {
legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)
if err != nil {
return nil, nil, nil, true, err
}
cliCfg = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
for _, c := range legacyProxyCfgs {
proxyCfgs = append(proxyCfgs, legacy.Convert_ProxyConf_To_v1(c))
}
for _, c := range legacyVisitorCfgs {
visitorCfgs = append(visitorCfgs, legacy.Convert_VisitorConf_To_v1(c))
}
isLegacyFormat = true
} else {
allCfg := v1.ClientConfig{}
if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {
return nil, nil, nil, false, err
}
cliCfg = &allCfg.ClientCommonConfig
for _, c := range allCfg.Proxies {
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
}
for _, c := range allCfg.Visitors {
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
}
result, err := LoadClientConfigResult(path, strict)
if err != nil {
return nil, nil, nil, result != nil && result.IsLegacyFormat, err
}
// Load additional config from includes.
// legacy ini format already handle this in ParseClientConfig.
if len(cliCfg.IncludeConfigFiles) > 0 && !isLegacyFormat {
extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict)
if err != nil {
return nil, nil, nil, isLegacyFormat, err
}
proxyCfgs = append(proxyCfgs, extProxyCfgs...)
visitorCfgs = append(visitorCfgs, extVisitorCfgs...)
proxyCfgs := result.Proxies
visitorCfgs := result.Visitors
proxyCfgs, visitorCfgs = FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs)
proxyCfgs = CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = CompleteVisitorConfigurers(visitorCfgs)
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
}
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
proxyCfgs := proxies
for _, c := range proxyCfgs {
c.Complete()
}
return proxyCfgs
}
func CompleteVisitorConfigurers(visitors []v1.VisitorConfigurer) []v1.VisitorConfigurer {
visitorCfgs := visitors
for _, c := range visitorCfgs {
c.Complete()
}
return visitorCfgs
}
func FilterClientConfigurers(
common *v1.ClientCommonConfig,
proxies []v1.ProxyConfigurer,
visitors []v1.VisitorConfigurer,
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
if common == nil {
common = &v1.ClientCommonConfig{}
}
// Filter by start
if len(cliCfg.Start) > 0 {
startSet := sets.New(cliCfg.Start...)
proxyCfgs := proxies
visitorCfgs := visitors
// Filter by start across merged configurers from all sources.
// For example, store entries are also filtered by this set.
if len(common.Start) > 0 {
startSet := sets.New(common.Start...)
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {
return startSet.Has(c.GetBaseConfig().Name)
})
@@ -291,19 +362,7 @@ func LoadClientConfig(path string, strict bool) (
enabled := c.GetBaseConfig().Enabled
return enabled == nil || *enabled
})
if cliCfg != nil {
if err := cliCfg.Complete(); err != nil {
return nil, nil, nil, isLegacyFormat, err
}
}
for _, c := range proxyCfgs {
c.Complete(cliCfg.User)
}
for _, c := range visitorCfgs {
c.Complete(cliCfg)
}
return cliCfg, proxyCfgs, visitorCfgs, isLegacyFormat, nil
return proxyCfgs, visitorCfgs
}
func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {

View File

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

View File

@@ -0,0 +1,117 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"errors"
"fmt"
"sort"
"sync"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type Aggregator struct {
mu sync.RWMutex
configSource *ConfigSource
storeSource *StoreSource
}
func NewAggregator(configSource *ConfigSource) *Aggregator {
if configSource == nil {
configSource = NewConfigSource()
}
return &Aggregator{
configSource: configSource,
}
}
func (a *Aggregator) SetStoreSource(storeSource *StoreSource) {
a.mu.Lock()
defer a.mu.Unlock()
a.storeSource = storeSource
}
func (a *Aggregator) ConfigSource() *ConfigSource {
return a.configSource
}
func (a *Aggregator) StoreSource() *StoreSource {
return a.storeSource
}
func (a *Aggregator) getSourcesLocked() []Source {
sources := make([]Source, 0, 2)
if a.configSource != nil {
sources = append(sources, a.configSource)
}
if a.storeSource != nil {
sources = append(sources, a.storeSource)
}
return sources
}
func (a *Aggregator) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
a.mu.RLock()
entries := a.getSourcesLocked()
a.mu.RUnlock()
if len(entries) == 0 {
return nil, nil, errors.New("no sources configured")
}
proxyMap := make(map[string]v1.ProxyConfigurer)
visitorMap := make(map[string]v1.VisitorConfigurer)
for _, src := range entries {
proxies, visitors, err := src.Load()
if err != nil {
return nil, nil, fmt.Errorf("load source: %w", err)
}
for _, p := range proxies {
proxyMap[p.GetBaseConfig().Name] = p
}
for _, v := range visitors {
visitorMap[v.GetBaseConfig().Name] = v
}
}
proxies, visitors := a.mapsToSortedSlices(proxyMap, visitorMap)
return proxies, visitors, nil
}
func (a *Aggregator) mapsToSortedSlices(
proxyMap map[string]v1.ProxyConfigurer,
visitorMap map[string]v1.VisitorConfigurer,
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
proxies := make([]v1.ProxyConfigurer, 0, len(proxyMap))
for _, p := range proxyMap {
proxies = append(proxies, p)
}
sort.Slice(proxies, func(i, j int) bool {
return proxies[i].GetBaseConfig().Name < proxies[j].GetBaseConfig().Name
})
visitors := make([]v1.VisitorConfigurer, 0, len(visitorMap))
for _, v := range visitorMap {
visitors = append(visitors, v)
}
sort.Slice(visitors, func(i, j int) bool {
return visitors[i].GetBaseConfig().Name < visitors[j].GetBaseConfig().Name
})
return proxies, visitors
}

View File

@@ -0,0 +1,217 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// mockProxy creates a TCP proxy config for testing
func mockProxy(name string) v1.ProxyConfigurer {
cfg := &v1.TCPProxyConfig{}
cfg.Name = name
cfg.Type = "tcp"
cfg.LocalPort = 8080
cfg.RemotePort = 9090
return cfg
}
// mockVisitor creates a STCP visitor config for testing
func mockVisitor(name string) v1.VisitorConfigurer {
cfg := &v1.STCPVisitorConfig{}
cfg.Name = name
cfg.Type = "stcp"
cfg.ServerName = "test-server"
return cfg
}
func newTestStoreSource(t *testing.T) *StoreSource {
t.Helper()
path := filepath.Join(t.TempDir(), "store.json")
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(t, err)
return storeSource
}
func newTestAggregator(t *testing.T, storeSource *StoreSource) *Aggregator {
t.Helper()
configSource := NewConfigSource()
agg := NewAggregator(configSource)
if storeSource != nil {
agg.SetStoreSource(storeSource)
}
return agg
}
func TestNewAggregator_CreatesConfigSourceWhenNil(t *testing.T) {
require := require.New(t)
agg := NewAggregator(nil)
require.NotNil(agg)
require.NotNil(agg.ConfigSource())
require.Nil(agg.StoreSource())
}
func TestNewAggregator_WithoutStore(t *testing.T) {
require := require.New(t)
configSource := NewConfigSource()
agg := NewAggregator(configSource)
require.NotNil(agg)
require.Same(configSource, agg.ConfigSource())
require.Nil(agg.StoreSource())
}
func TestNewAggregator_WithStore(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
configSource := NewConfigSource()
agg := NewAggregator(configSource)
agg.SetStoreSource(storeSource)
require.Same(configSource, agg.ConfigSource())
require.Same(storeSource, agg.StoreSource())
}
func TestAggregator_SetStoreSource_Overwrite(t *testing.T) {
require := require.New(t)
agg := newTestAggregator(t, nil)
first := newTestStoreSource(t)
second := newTestStoreSource(t)
agg.SetStoreSource(first)
require.Same(first, agg.StoreSource())
agg.SetStoreSource(second)
require.Same(second, agg.StoreSource())
agg.SetStoreSource(nil)
require.Nil(agg.StoreSource())
}
func TestAggregator_MergeBySourceOrder(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
configSource := agg.ConfigSource()
configShared := mockProxy("shared").(*v1.TCPProxyConfig)
configShared.LocalPort = 1111
configOnly := mockProxy("only-in-config").(*v1.TCPProxyConfig)
configOnly.LocalPort = 1112
err := configSource.ReplaceAll([]v1.ProxyConfigurer{configShared, configOnly}, nil)
require.NoError(err)
storeShared := mockProxy("shared").(*v1.TCPProxyConfig)
storeShared.LocalPort = 2222
storeOnly := mockProxy("only-in-store").(*v1.TCPProxyConfig)
storeOnly.LocalPort = 2223
err = storeSource.AddProxy(storeShared)
require.NoError(err)
err = storeSource.AddProxy(storeOnly)
require.NoError(err)
proxies, visitors, err := agg.Load()
require.NoError(err)
require.Len(visitors, 0)
require.Len(proxies, 3)
var sharedProxy *v1.TCPProxyConfig
for _, p := range proxies {
if p.GetBaseConfig().Name == "shared" {
sharedProxy = p.(*v1.TCPProxyConfig)
break
}
}
require.NotNil(sharedProxy)
require.Equal(2222, sharedProxy.LocalPort)
}
func TestAggregator_DisabledEntryIsSourceLocalFilter(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
configSource := agg.ConfigSource()
lowProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
lowProxy.LocalPort = 1111
err := configSource.ReplaceAll([]v1.ProxyConfigurer{lowProxy}, nil)
require.NoError(err)
disabled := false
highProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
highProxy.LocalPort = 2222
highProxy.Enabled = &disabled
err = storeSource.AddProxy(highProxy)
require.NoError(err)
proxies, visitors, err := agg.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 0)
proxy := proxies[0].(*v1.TCPProxyConfig)
require.Equal("shared-proxy", proxy.Name)
require.Equal(1111, proxy.LocalPort)
}
func TestAggregator_VisitorMerge(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
err := agg.ConfigSource().ReplaceAll(nil, []v1.VisitorConfigurer{mockVisitor("visitor1")})
require.NoError(err)
err = storeSource.AddVisitor(mockVisitor("visitor2"))
require.NoError(err)
_, visitors, err := agg.Load()
require.NoError(err)
require.Len(visitors, 2)
}
func TestAggregator_Load_ReturnsDefensiveCopies(t *testing.T) {
require := require.New(t)
agg := newTestAggregator(t, nil)
err := agg.ConfigSource().ReplaceAll([]v1.ProxyConfigurer{mockProxy("ssh")}, nil)
require.NoError(err)
proxies, _, err := agg.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Equal("ssh", proxies[0].GetBaseConfig().Name)
proxies[0].GetBaseConfig().Name = "alice.ssh"
proxies2, _, err := agg.Load()
require.NoError(err)
require.Len(proxies2, 1)
require.Equal("ssh", proxies2[0].GetBaseConfig().Name)
}

View File

@@ -0,0 +1,65 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"sync"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// baseSource provides shared state and behavior for Source implementations.
// It manages proxy/visitor storage.
// Concrete types (ConfigSource, StoreSource) embed this struct.
type baseSource struct {
mu sync.RWMutex
proxies map[string]v1.ProxyConfigurer
visitors map[string]v1.VisitorConfigurer
}
func newBaseSource() baseSource {
return baseSource{
proxies: make(map[string]v1.ProxyConfigurer),
visitors: make(map[string]v1.VisitorConfigurer),
}
}
// Load returns all enabled proxy and visitor configurations.
// Configurations with Enabled explicitly set to false are filtered out.
func (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
proxies := make([]v1.ProxyConfigurer, 0, len(s.proxies))
for _, p := range s.proxies {
// Filter out disabled proxies (nil or true means enabled)
if enabled := p.GetBaseConfig().Enabled; enabled != nil && !*enabled {
continue
}
proxies = append(proxies, p)
}
visitors := make([]v1.VisitorConfigurer, 0, len(s.visitors))
for _, v := range s.visitors {
// Filter out disabled visitors (nil or true means enabled)
if enabled := v.GetBaseConfig().Enabled; enabled != nil && !*enabled {
continue
}
visitors = append(visitors, v)
}
return cloneConfigurers(proxies, visitors)
}

View File

@@ -0,0 +1,48 @@
package source
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestBaseSourceLoadReturnsClonedConfigurers(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
proxyCfg := &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "proxy1",
Type: "tcp",
},
}
visitorCfg := &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Name: "visitor1",
Type: "stcp",
},
}
err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
require.NoError(err)
firstProxies, firstVisitors, err := src.Load()
require.NoError(err)
require.Len(firstProxies, 1)
require.Len(firstVisitors, 1)
// Mutate loaded objects as runtime completion would do.
firstProxies[0].Complete()
firstVisitors[0].Complete()
secondProxies, secondVisitors, err := src.Load()
require.NoError(err)
require.Len(secondProxies, 1)
require.Len(secondVisitors, 1)
require.Empty(secondProxies[0].GetBaseConfig().LocalIP)
require.Empty(secondVisitors[0].GetBaseConfig().BindAddr)
}

View File

@@ -0,0 +1,43 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func cloneConfigurers(
proxies []v1.ProxyConfigurer,
visitors []v1.VisitorConfigurer,
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
clonedProxies := make([]v1.ProxyConfigurer, 0, len(proxies))
clonedVisitors := make([]v1.VisitorConfigurer, 0, len(visitors))
for _, cfg := range proxies {
if cfg == nil {
return nil, nil, fmt.Errorf("proxy cannot be nil")
}
clonedProxies = append(clonedProxies, cfg.Clone())
}
for _, cfg := range visitors {
if cfg == nil {
return nil, nil, fmt.Errorf("visitor cannot be nil")
}
clonedVisitors = append(clonedVisitors, cfg.Clone())
}
return clonedProxies, clonedVisitors, nil
}

View File

@@ -0,0 +1,65 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// ConfigSource implements Source for in-memory configuration.
// All operations are thread-safe.
type ConfigSource struct {
baseSource
}
func NewConfigSource() *ConfigSource {
return &ConfigSource{
baseSource: newBaseSource(),
}
}
// ReplaceAll replaces all proxy and visitor configurations atomically.
func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
s.mu.Lock()
defer s.mu.Unlock()
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
for _, p := range proxies {
if p == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := p.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
nextProxies[name] = p
}
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
for _, v := range visitors {
if v == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := v.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
nextVisitors[name] = v
}
s.proxies = nextProxies
s.visitors = nextVisitors
return nil
}

View File

@@ -0,0 +1,173 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestNewConfigSource(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
require.NotNil(src)
}
func TestConfigSource_ReplaceAll(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
err := src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
)
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2)
require.Len(visitors, 1)
// ReplaceAll again should replace everything
err = src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy3")},
nil,
)
require.NoError(err)
proxies, visitors, err = src.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 0)
require.Equal("proxy3", proxies[0].GetBaseConfig().Name)
// ReplaceAll with nil proxy should fail
err = src.ReplaceAll([]v1.ProxyConfigurer{nil}, nil)
require.Error(err)
// ReplaceAll with empty name proxy should fail
err = src.ReplaceAll([]v1.ProxyConfigurer{&v1.TCPProxyConfig{}}, nil)
require.Error(err)
}
func TestConfigSource_Load(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
err := src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
)
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2)
require.Len(visitors, 1)
}
// TestConfigSource_Load_FiltersDisabled verifies that Load() filters out
// proxies and visitors with Enabled explicitly set to false.
func TestConfigSource_Load_FiltersDisabled(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
disabled := false
enabled := true
// Create enabled proxy (nil Enabled = enabled by default)
enabledProxy := mockProxy("enabled-proxy")
// Create disabled proxy
disabledProxy := &v1.TCPProxyConfig{}
disabledProxy.Name = "disabled-proxy"
disabledProxy.Type = "tcp"
disabledProxy.Enabled = &disabled
// Create explicitly enabled proxy
explicitEnabledProxy := &v1.TCPProxyConfig{}
explicitEnabledProxy.Name = "explicit-enabled-proxy"
explicitEnabledProxy.Type = "tcp"
explicitEnabledProxy.Enabled = &enabled
// Create enabled visitor (nil Enabled = enabled by default)
enabledVisitor := mockVisitor("enabled-visitor")
// Create disabled visitor
disabledVisitor := &v1.STCPVisitorConfig{}
disabledVisitor.Name = "disabled-visitor"
disabledVisitor.Type = "stcp"
disabledVisitor.Enabled = &disabled
err := src.ReplaceAll(
[]v1.ProxyConfigurer{enabledProxy, disabledProxy, explicitEnabledProxy},
[]v1.VisitorConfigurer{enabledVisitor, disabledVisitor},
)
require.NoError(err)
// Load should filter out disabled configs
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2, "Should have 2 enabled proxies")
require.Len(visitors, 1, "Should have 1 enabled visitor")
// Verify the correct proxies are returned
proxyNames := make([]string, 0, len(proxies))
for _, p := range proxies {
proxyNames = append(proxyNames, p.GetBaseConfig().Name)
}
require.Contains(proxyNames, "enabled-proxy")
require.Contains(proxyNames, "explicit-enabled-proxy")
require.NotContains(proxyNames, "disabled-proxy")
// Verify the correct visitor is returned
require.Equal("enabled-visitor", visitors[0].GetBaseConfig().Name)
}
func TestConfigSource_ReplaceAll_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 1)
require.Empty(proxies[0].GetBaseConfig().LocalIP)
require.Empty(visitors[0].GetBaseConfig().BindAddr)
require.Empty(visitors[0].(*v1.XTCPVisitorConfig).Protocol)
}

View File

@@ -0,0 +1,37 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// Source is the interface for configuration sources.
// A Source provides proxy and visitor configurations from various backends.
// Aggregator currently uses the built-in config source as base and an optional
// store source as higher-priority overlay.
type Source interface {
// Load loads the proxy and visitor configurations from this source.
// Returns the loaded configurations and any error encountered.
// A disabled entry in one source is source-local filtering, not a cross-source
// tombstone for entries from lower-priority sources.
//
// Error handling contract with Aggregator:
// - When err is nil, returned slices are consumed.
// - When err is non-nil, Aggregator aborts the merge and returns the error.
// - To publish best-effort or partial results, return those results with
// err set to nil.
Load() (proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, err error)
}

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

@@ -0,0 +1,359 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type StoreSourceConfig struct {
Path string `json:"path"`
}
type storeData struct {
Proxies []v1.TypedProxyConfig `json:"proxies,omitempty"`
Visitors []v1.TypedVisitorConfig `json:"visitors,omitempty"`
}
type StoreSource struct {
baseSource
config StoreSourceConfig
}
var (
ErrAlreadyExists = errors.New("already exists")
ErrNotFound = errors.New("not found")
)
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("path is required")
}
s := &StoreSource{
baseSource: newBaseSource(),
config: cfg,
}
if err := s.loadFromFile(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load existing data: %w", err)
}
}
return s, nil
}
func (s *StoreSource) loadFromFile() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.loadFromFileUnlocked()
}
func (s *StoreSource) loadFromFileUnlocked() error {
data, err := os.ReadFile(s.config.Path)
if err != nil {
return err
}
var stored storeData
if err := v1.WithDisallowUnknownFields(false, func() error {
return json.Unmarshal(data, &stored)
}); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
}
s.proxies = make(map[string]v1.ProxyConfigurer)
s.visitors = make(map[string]v1.VisitorConfigurer)
for _, tp := range stored.Proxies {
if tp.ProxyConfigurer != nil {
proxyCfg := tp.ProxyConfigurer
name := proxyCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.proxies[name] = proxyCfg
}
}
for _, tv := range stored.Visitors {
if tv.VisitorConfigurer != nil {
visitorCfg := tv.VisitorConfigurer
name := visitorCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.visitors[name] = visitorCfg
}
}
return nil
}
func (s *StoreSource) saveToFileUnlocked() error {
stored := storeData{
Proxies: make([]v1.TypedProxyConfig, 0, len(s.proxies)),
Visitors: make([]v1.TypedVisitorConfig, 0, len(s.visitors)),
}
for _, p := range s.proxies {
stored.Proxies = append(stored.Proxies, v1.TypedProxyConfig{ProxyConfigurer: p})
}
for _, v := range s.visitors {
stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v})
}
data, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
dir := filepath.Dir(s.config.Path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
tmpPath := s.config.Path + ".tmp"
f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to close temp file: %w", err)
}
if err := os.Rename(tmpPath, s.config.Path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := proxy.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.proxies[name]; exists {
return fmt.Errorf("%w: proxy %q", ErrAlreadyExists, name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
delete(s.proxies, name)
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := proxy.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) RemoveProxy(name string) error {
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
}
delete(s.proxies, name)
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
s.mu.RLock()
defer s.mu.RUnlock()
p, exists := s.proxies[name]
if !exists {
return nil
}
return p
}
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := visitor.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.visitors[name]; exists {
return fmt.Errorf("%w: visitor %q", ErrAlreadyExists, name)
}
s.visitors[name] = visitor
if err := s.saveToFileUnlocked(); err != nil {
delete(s.visitors, name)
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := visitor.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
}
s.visitors[name] = visitor
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) RemoveVisitor(name string) error {
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
}
delete(s.visitors, name)
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
s.mu.RLock()
defer s.mu.RUnlock()
v, exists := s.visitors[name]
if !exists {
return nil
}
return v
}
func (s *StoreSource) GetAllProxies() ([]v1.ProxyConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]v1.ProxyConfigurer, 0, len(s.proxies))
for _, p := range s.proxies {
result = append(result, p)
}
return result, nil
}
func (s *StoreSource) GetAllVisitors() ([]v1.VisitorConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]v1.VisitorConfigurer, 0, len(s.visitors))
for _, v := range s.visitors {
result = append(result, v)
}
return result, nil
}

View File

@@ -0,0 +1,144 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func setDisallowUnknownFieldsForStoreTest(t *testing.T, value bool) func() {
t.Helper()
v1.DisallowUnknownFieldsMu.Lock()
prev := v1.DisallowUnknownFields
v1.DisallowUnknownFields = value
v1.DisallowUnknownFieldsMu.Unlock()
return func() {
v1.DisallowUnknownFieldsMu.Lock()
v1.DisallowUnknownFields = prev
v1.DisallowUnknownFieldsMu.Unlock()
}
}
func getDisallowUnknownFieldsForStoreTest() bool {
v1.DisallowUnknownFieldsMu.Lock()
defer v1.DisallowUnknownFieldsMu.Unlock()
return v1.DisallowUnknownFields
}
func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "store.json")
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(err)
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
err = storeSource.AddProxy(proxyCfg)
require.NoError(err)
err = storeSource.AddVisitor(visitorCfg)
require.NoError(err)
gotProxy := storeSource.GetProxy("proxy1")
require.NotNil(gotProxy)
require.Empty(gotProxy.GetBaseConfig().LocalIP)
gotVisitor := storeSource.GetVisitor("visitor1")
require.NotNil(gotVisitor)
require.Empty(gotVisitor.GetBaseConfig().BindAddr)
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
}
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "store.json")
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
stored := storeData{
Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}},
Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}},
}
data, err := json.Marshal(stored)
require.NoError(err)
err = os.WriteFile(path, data, 0o600)
require.NoError(err)
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(err)
gotProxy := storeSource.GetProxy("proxy1")
require.NotNil(gotProxy)
require.Empty(gotProxy.GetBaseConfig().LocalIP)
gotVisitor := storeSource.GetVisitor("visitor1")
require.NotNil(gotVisitor)
require.Empty(gotVisitor.GetBaseConfig().BindAddr)
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
}
func TestStoreSource_LoadFromFile_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) {
require := require.New(t)
restore := setDisallowUnknownFieldsForStoreTest(t, true)
t.Cleanup(restore)
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"))
require.True(getDisallowUnknownFieldsForStoreTest())
}

View File

@@ -37,6 +37,8 @@ type ClientCommonConfig struct {
// clients. If this value is not "", proxy names will automatically be
// changed to "{user}.{proxy_name}".
User string `json:"user,omitempty"`
// ClientID uniquely identifies this frpc instance.
ClientID string `json:"clientID,omitempty"`
// ServerAddr specifies the address of the server to connect to. By
// default, this value is "0.0.0.0".
@@ -75,6 +77,9 @@ type ClientCommonConfig struct {
// Include other config files for proxies.
IncludeConfigFiles []string `json:"includes,omitempty"`
// Store config enables the built-in store source (not configurable via sources list).
Store StoreConfig `json:"store,omitempty"`
}
func (c *ClientCommonConfig) Complete() error {

109
pkg/config/v1/clone_test.go Normal file
View File

@@ -0,0 +1,109 @@
package v1
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestProxyCloneDeepCopy(t *testing.T) {
require := require.New(t)
enabled := true
pluginHTTP2 := true
cfg := &HTTPProxyConfig{
ProxyBaseConfig: ProxyBaseConfig{
Name: "p1",
Type: "http",
Enabled: &enabled,
Annotations: map[string]string{"a": "1"},
Metadatas: map[string]string{"m": "1"},
HealthCheck: HealthCheckConfig{
Type: "http",
HTTPHeaders: []HTTPHeader{
{Name: "X-Test", Value: "v1"},
},
},
ProxyBackend: ProxyBackend{
Plugin: TypedClientPluginOptions{
Type: PluginHTTPS2HTTP,
ClientPluginOptions: &HTTPS2HTTPPluginOptions{
Type: PluginHTTPS2HTTP,
EnableHTTP2: &pluginHTTP2,
RequestHeaders: HeaderOperations{Set: map[string]string{"k": "v"}},
},
},
},
},
DomainConfig: DomainConfig{
CustomDomains: []string{"a.example.com"},
SubDomain: "a",
},
Locations: []string{"/api"},
RequestHeaders: HeaderOperations{Set: map[string]string{"h1": "v1"}},
ResponseHeaders: HeaderOperations{Set: map[string]string{"h2": "v2"}},
}
cloned := cfg.Clone().(*HTTPProxyConfig)
*cloned.Enabled = false
cloned.Annotations["a"] = "changed"
cloned.Metadatas["m"] = "changed"
cloned.HealthCheck.HTTPHeaders[0].Value = "changed"
cloned.CustomDomains[0] = "b.example.com"
cloned.Locations[0] = "/new"
cloned.RequestHeaders.Set["h1"] = "changed"
cloned.ResponseHeaders.Set["h2"] = "changed"
clientPlugin := cloned.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions)
*clientPlugin.EnableHTTP2 = false
clientPlugin.RequestHeaders.Set["k"] = "changed"
require.True(*cfg.Enabled)
require.Equal("1", cfg.Annotations["a"])
require.Equal("1", cfg.Metadatas["m"])
require.Equal("v1", cfg.HealthCheck.HTTPHeaders[0].Value)
require.Equal("a.example.com", cfg.CustomDomains[0])
require.Equal("/api", cfg.Locations[0])
require.Equal("v1", cfg.RequestHeaders.Set["h1"])
require.Equal("v2", cfg.ResponseHeaders.Set["h2"])
origPlugin := cfg.Plugin.ClientPluginOptions.(*HTTPS2HTTPPluginOptions)
require.True(*origPlugin.EnableHTTP2)
require.Equal("v", origPlugin.RequestHeaders.Set["k"])
}
func TestVisitorCloneDeepCopy(t *testing.T) {
require := require.New(t)
enabled := true
cfg := &XTCPVisitorConfig{
VisitorBaseConfig: VisitorBaseConfig{
Name: "v1",
Type: "xtcp",
Enabled: &enabled,
ServerName: "server",
BindPort: 7000,
Plugin: TypedVisitorPluginOptions{
Type: VisitorPluginVirtualNet,
VisitorPluginOptions: &VirtualNetVisitorPluginOptions{
Type: VisitorPluginVirtualNet,
DestinationIP: "10.0.0.1",
},
},
},
NatTraversal: &NatTraversalConfig{
DisableAssistedAddrs: true,
},
}
cloned := cfg.Clone().(*XTCPVisitorConfig)
*cloned.Enabled = false
cloned.NatTraversal.DisableAssistedAddrs = false
visitorPlugin := cloned.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions)
visitorPlugin.DestinationIP = "10.0.0.2"
require.True(*cfg.Enabled)
require.True(cfg.NatTraversal.DisableAssistedAddrs)
origPlugin := cfg.Plugin.VisitorPluginOptions.(*VirtualNetVisitorPluginOptions)
require.Equal("10.0.0.1", origPlugin.DestinationIP)
}

View File

@@ -15,15 +15,15 @@
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.
// TODO(fatedier): Migrate typed config decoding to encoding/json/v2 when it is stable for production use.
// The current encoding/json(v1) path cannot propagate DisallowUnknownFields into custom UnmarshalJSON
// methods, so we temporarily keep this global strictness flag protected by a mutex.
//
// https://github.com/golang/go/issues/41144
// https://github.com/golang/go/discussions/63397
@@ -32,6 +32,19 @@ var (
DisallowUnknownFieldsMu sync.Mutex
)
// WithDisallowUnknownFields temporarily overrides typed config JSON strictness.
// It restores the previous value before returning.
func WithDisallowUnknownFields(disallow bool, fn func() error) error {
DisallowUnknownFieldsMu.Lock()
prev := DisallowUnknownFields
DisallowUnknownFields = disallow
defer func() {
DisallowUnknownFields = prev
DisallowUnknownFieldsMu.Unlock()
}()
return fn()
}
type AuthScope string
const (
@@ -104,6 +117,14 @@ type NatTraversalConfig struct {
DisableAssistedAddrs bool `json:"disableAssistedAddrs,omitempty"`
}
func (c *NatTraversalConfig) Clone() *NatTraversalConfig {
if c == nil {
return nil
}
out := *c
return &out
}
type LogConfig struct {
// This is destination where frp should write the logs.
// If "console" is used, logs will be printed to stdout, otherwise,
@@ -138,6 +159,12 @@ type HeaderOperations struct {
Set map[string]string `json:"set,omitempty"`
}
func (o HeaderOperations) Clone() HeaderOperations {
return HeaderOperations{
Set: maps.Clone(o.Set),
}
}
type HTTPHeader struct {
Name string `json:"name"`
Value string `json:"value"`

View File

@@ -19,9 +19,9 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"reflect"
"github.com/samber/lo"
"slices"
"github.com/fatedier/frp/pkg/config/types"
"github.com/fatedier/frp/pkg/msg"
@@ -102,11 +102,23 @@ type HealthCheckConfig struct {
HTTPHeaders []HTTPHeader `json:"httpHeaders,omitempty"`
}
func (c HealthCheckConfig) Clone() HealthCheckConfig {
out := c
out.HTTPHeaders = slices.Clone(c.HTTPHeaders)
return out
}
type DomainConfig struct {
CustomDomains []string `json:"customDomains,omitempty"`
SubDomain string `json:"subdomain,omitempty"`
}
func (c DomainConfig) Clone() DomainConfig {
out := c
out.CustomDomains = slices.Clone(c.CustomDomains)
return out
}
type ProxyBaseConfig struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -122,12 +134,27 @@ type ProxyBaseConfig struct {
ProxyBackend
}
func (c ProxyBaseConfig) Clone() ProxyBaseConfig {
out := c
out.Enabled = util.ClonePtr(c.Enabled)
out.Annotations = maps.Clone(c.Annotations)
out.Metadatas = maps.Clone(c.Metadatas)
out.HealthCheck = c.HealthCheck.Clone()
out.ProxyBackend = c.ProxyBackend.Clone()
return out
}
func (c ProxyBackend) Clone() ProxyBackend {
out := c
out.Plugin = c.Plugin.Clone()
return out
}
func (c *ProxyBaseConfig) GetBaseConfig() *ProxyBaseConfig {
return c
}
func (c *ProxyBaseConfig) Complete(namePrefix string) {
c.Name = lo.Ternary(namePrefix == "", "", namePrefix+".") + c.Name
func (c *ProxyBaseConfig) Complete() {
c.LocalIP = util.EmptyOr(c.LocalIP, "127.0.0.1")
c.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient)
@@ -207,8 +234,9 @@ func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {
}
type ProxyConfigurer interface {
Complete(namePrefix string)
Complete()
GetBaseConfig() *ProxyBaseConfig
Clone() ProxyConfigurer
// MarshalToMsg marshals this config into a msg.NewProxy message. This
// function will be called on the frpc side.
MarshalToMsg(*msg.NewProxy)
@@ -271,6 +299,12 @@ func (c *TCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RemotePort = m.RemotePort
}
func (c *TCPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
return &out
}
var _ ProxyConfigurer = &UDPProxyConfig{}
type UDPProxyConfig struct {
@@ -291,6 +325,12 @@ func (c *UDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RemotePort = m.RemotePort
}
func (c *UDPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
return &out
}
var _ ProxyConfigurer = &HTTPProxyConfig{}
type HTTPProxyConfig struct {
@@ -334,6 +374,16 @@ func (c *HTTPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RouteByHTTPUser = m.RouteByHTTPUser
}
func (c *HTTPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.DomainConfig = c.DomainConfig.Clone()
out.Locations = slices.Clone(c.Locations)
out.RequestHeaders = c.RequestHeaders.Clone()
out.ResponseHeaders = c.ResponseHeaders.Clone()
return &out
}
var _ ProxyConfigurer = &HTTPSProxyConfig{}
type HTTPSProxyConfig struct {
@@ -355,6 +405,13 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.SubDomain = m.SubDomain
}
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.DomainConfig = c.DomainConfig.Clone()
return &out
}
type TCPMultiplexerType string
const (
@@ -395,6 +452,13 @@ func (c *TCPMuxProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RouteByHTTPUser = m.RouteByHTTPUser
}
func (c *TCPMuxProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.DomainConfig = c.DomainConfig.Clone()
return &out
}
var _ ProxyConfigurer = &STCPProxyConfig{}
type STCPProxyConfig struct {
@@ -418,6 +482,13 @@ func (c *STCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.AllowUsers = m.AllowUsers
}
func (c *STCPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.AllowUsers = slices.Clone(c.AllowUsers)
return &out
}
var _ ProxyConfigurer = &XTCPProxyConfig{}
type XTCPProxyConfig struct {
@@ -444,6 +515,14 @@ func (c *XTCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.AllowUsers = m.AllowUsers
}
func (c *XTCPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.AllowUsers = slices.Clone(c.AllowUsers)
out.NatTraversal = c.NatTraversal.Clone()
return &out
}
var _ ProxyConfigurer = &SUDPProxyConfig{}
type SUDPProxyConfig struct {
@@ -466,3 +545,10 @@ func (c *SUDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.Secretkey = m.Sk
c.AllowUsers = m.AllowUsers
}
func (c *SUDPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.AllowUsers = slices.Clone(c.AllowUsers)
return &out
}

View File

@@ -54,6 +54,7 @@ var clientPluginOptionsTypeMap = map[string]reflect.Type{
type ClientPluginOptions interface {
Complete()
Clone() ClientPluginOptions
}
type TypedClientPluginOptions struct {
@@ -61,6 +62,14 @@ 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 {
if len(b) == 4 && string(b) == "null" {
return nil
@@ -109,6 +118,15 @@ type HTTP2HTTPSPluginOptions struct {
func (o *HTTP2HTTPSPluginOptions) Complete() {}
func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
return &out
}
type HTTPProxyPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPUser string `json:"httpUser,omitempty"`
@@ -117,6 +135,14 @@ type HTTPProxyPluginOptions struct {
func (o *HTTPProxyPluginOptions) Complete() {}
func (o *HTTPProxyPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type HTTPS2HTTPPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -131,6 +157,16 @@ func (o *HTTPS2HTTPPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
}
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
return &out
}
type HTTPS2HTTPSPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -145,6 +181,16 @@ func (o *HTTPS2HTTPSPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
}
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
return &out
}
type HTTP2HTTPPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -154,6 +200,15 @@ type HTTP2HTTPPluginOptions struct {
func (o *HTTP2HTTPPluginOptions) Complete() {}
func (o *HTTP2HTTPPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
return &out
}
type Socks5PluginOptions struct {
Type string `json:"type,omitempty"`
Username string `json:"username,omitempty"`
@@ -162,6 +217,14 @@ type Socks5PluginOptions struct {
func (o *Socks5PluginOptions) Complete() {}
func (o *Socks5PluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type StaticFilePluginOptions struct {
Type string `json:"type,omitempty"`
LocalPath string `json:"localPath,omitempty"`
@@ -172,6 +235,14 @@ type StaticFilePluginOptions struct {
func (o *StaticFilePluginOptions) Complete() {}
func (o *StaticFilePluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type UnixDomainSocketPluginOptions struct {
Type string `json:"type,omitempty"`
UnixPath string `json:"unixPath,omitempty"`
@@ -179,6 +250,14 @@ type UnixDomainSocketPluginOptions struct {
func (o *UnixDomainSocketPluginOptions) Complete() {}
func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type TLS2RawPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -188,8 +267,24 @@ type TLS2RawPluginOptions struct {
func (o *TLS2RawPluginOptions) Complete() {}
func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type VirtualNetPluginOptions struct {
Type string `json:"type,omitempty"`
}
func (o *VirtualNetPluginOptions) Complete() {}
func (o *VirtualNetPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}

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

@@ -0,0 +1,26 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package v1
// StoreConfig configures the built-in store source.
type StoreConfig struct {
// Path is the store file path.
Path string `json:"path,omitempty"`
}
// IsEnabled returns true if the store is configured with a valid path.
func (c *StoreConfig) IsEnabled() bool {
return c.Path != ""
}

View File

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

View File

@@ -21,8 +21,6 @@ import (
"fmt"
"reflect"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -52,31 +50,27 @@ type VisitorBaseConfig struct {
Plugin TypedVisitorPluginOptions `json:"plugin,omitempty"`
}
func (c VisitorBaseConfig) Clone() VisitorBaseConfig {
out := c
out.Enabled = util.ClonePtr(c.Enabled)
out.Plugin = c.Plugin.Clone()
return out
}
func (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig {
return c
}
func (c *VisitorBaseConfig) Complete(g *ClientCommonConfig) {
func (c *VisitorBaseConfig) Complete() {
if c.BindAddr == "" {
c.BindAddr = "127.0.0.1"
}
namePrefix := ""
if g.User != "" {
namePrefix = g.User + "."
}
c.Name = namePrefix + c.Name
if c.ServerUser != "" {
c.ServerName = c.ServerUser + "." + c.ServerName
} else {
c.ServerName = namePrefix + c.ServerName
}
}
type VisitorConfigurer interface {
Complete(*ClientCommonConfig)
Complete()
GetBaseConfig() *VisitorBaseConfig
Clone() VisitorConfigurer
}
type VisitorType string
@@ -146,12 +140,24 @@ type STCPVisitorConfig struct {
VisitorBaseConfig
}
func (c *STCPVisitorConfig) Clone() VisitorConfigurer {
out := *c
out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
return &out
}
var _ VisitorConfigurer = &SUDPVisitorConfig{}
type SUDPVisitorConfig struct {
VisitorBaseConfig
}
func (c *SUDPVisitorConfig) Clone() VisitorConfigurer {
out := *c
out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
return &out
}
var _ VisitorConfigurer = &XTCPVisitorConfig{}
type XTCPVisitorConfig struct {
@@ -168,15 +174,18 @@ type XTCPVisitorConfig struct {
NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
}
func (c *XTCPVisitorConfig) Complete(g *ClientCommonConfig) {
c.VisitorBaseConfig.Complete(g)
func (c *XTCPVisitorConfig) Complete() {
c.VisitorBaseConfig.Complete()
c.Protocol = util.EmptyOr(c.Protocol, "quic")
c.MaxRetriesAnHour = util.EmptyOr(c.MaxRetriesAnHour, 8)
c.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90)
c.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000)
if c.FallbackTo != "" {
c.FallbackTo = lo.Ternary(g.User == "", "", g.User+".") + c.FallbackTo
}
}
func (c *XTCPVisitorConfig) Clone() VisitorConfigurer {
out := *c
out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
out.NatTraversal = c.NatTraversal.Clone()
return &out
}

View File

@@ -32,6 +32,7 @@ var visitorPluginOptionsTypeMap = map[string]reflect.Type{
type VisitorPluginOptions interface {
Complete()
Clone() VisitorPluginOptions
}
type TypedVisitorPluginOptions struct {
@@ -39,6 +40,14 @@ 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 {
if len(b) == 4 && string(b) == "null" {
return nil
@@ -84,3 +93,11 @@ type VirtualNetVisitorPluginOptions struct {
}
func (o *VirtualNetVisitorPluginOptions) Complete() {}
func (o *VirtualNetVisitorPluginOptions) Clone() VisitorPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}

View File

@@ -56,9 +56,9 @@ func (m *serverMetrics) CloseClient() {
}
}
func (m *serverMetrics) NewProxy(name string, proxyType string) {
func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
for _, v := range m.ms {
v.NewProxy(name, proxyType)
v.NewProxy(name, proxyType, user, clientID)
}
}

View File

@@ -98,7 +98,7 @@ func (m *serverMetrics) CloseClient() {
m.info.ClientCounts.Dec(1)
}
func (m *serverMetrics) NewProxy(name string, proxyType string) {
func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
m.mu.Lock()
defer m.mu.Unlock()
counter, ok := m.info.ProxyTypeCounts[proxyType]
@@ -119,6 +119,8 @@ func (m *serverMetrics) NewProxy(name string, proxyType string) {
}
m.info.ProxyStatistics[name] = proxyStats
}
proxyStats.User = user
proxyStats.ClientID = clientID
proxyStats.LastStartTime = time.Now()
}
@@ -214,6 +216,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
ps := &ProxyStats{
Name: name,
Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()),
@@ -245,6 +249,8 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
res = &ProxyStats{
Name: name,
Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()),
@@ -260,6 +266,31 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
return
}
func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {
m.mu.Lock()
defer m.mu.Unlock()
proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok {
res = &ProxyStats{
Name: proxyName,
Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()),
}
if !proxyStats.LastStartTime.IsZero() {
res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
}
if !proxyStats.LastCloseTime.IsZero() {
res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
}
}
return
}
func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) {
m.mu.Lock()
defer m.mu.Unlock()

View File

@@ -35,6 +35,8 @@ type ServerStats struct {
type ProxyStats struct {
Name string
Type string
User string
ClientID string
TodayTrafficIn int64
TodayTrafficOut int64
LastStartTime string
@@ -51,6 +53,8 @@ type ProxyTrafficInfo struct {
type ProxyStatistics struct {
Name string
ProxyType string
User string
ClientID string
TrafficIn metric.DateCounter
TrafficOut metric.DateCounter
CurConns metric.Counter
@@ -78,6 +82,7 @@ type Collector interface {
GetServer() *ServerStats
GetProxiesByType(proxyType string) []*ProxyStats
GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats
GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int)
}

View File

@@ -30,7 +30,7 @@ func (m *serverMetrics) CloseClient() {
m.clientCount.Dec()
}
func (m *serverMetrics) NewProxy(name string, proxyType string) {
func (m *serverMetrics) NewProxy(name string, proxyType string, _ string, _ string) {
m.proxyCount.WithLabelValues(proxyType).Inc()
m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()
}

View File

@@ -82,6 +82,7 @@ type Login struct {
PrivilegeKey string `json:"privilege_key,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
RunID string `json:"run_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
Metas map[string]string `json:"metas,omitempty"`
// Currently only effective for VirtualClient.

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

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

27
pkg/naming/names_test.go Normal file
View File

@@ -0,0 +1,27 @@
package naming
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAddUserPrefix(t *testing.T) {
require := require.New(t)
require.Equal("test", AddUserPrefix("", "test"))
require.Equal("alice.test", AddUserPrefix("alice", "test"))
}
func TestStripUserPrefix(t *testing.T) {
require := require.New(t)
require.Equal("test", StripUserPrefix("", "test"))
require.Equal("test", StripUserPrefix("alice", "alice.test"))
require.Equal("alice.test", StripUserPrefix("alice", "alice.alice.test"))
require.Equal("bob.test", StripUserPrefix("alice", "bob.test"))
}
func TestBuildTargetServerProxyName(t *testing.T) {
require := require.New(t)
require.Equal("alice.test", BuildTargetServerProxyName("alice", "", "test"))
require.Equal("bob.test", BuildTargetServerProxyName("alice", "bob", "test"))
}

View File

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

View File

@@ -171,8 +171,9 @@ func (f *featureGate) Add(features map[Feature]FeatureSpec) error {
// String returns a string containing all enabled feature gates, formatted as "key1=value1,key2=value2,..."
func (f *featureGate) String() string {
pairs := []string{}
for k, v := range f.enabled.Load().(map[Feature]bool) {
enabled := f.enabled.Load().(map[Feature]bool)
pairs := make([]string, 0, len(enabled))
for k, v := range enabled {
pairs = append(pairs, fmt.Sprintf("%s=%t", k, v))
}
sort.Strings(pairs)

View File

@@ -124,8 +124,8 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
}
mu.Unlock()
// Add proxy protocol header if configured
if proxyProtocolVersion != "" && udpMsg.RemoteAddr != nil {
// Add proxy protocol header if configured (only for the first packet of a new connection)
if !ok && proxyProtocolVersion != "" && udpMsg.RemoteAddr != nil {
ppBuf, err := netpkg.BuildProxyProtocolHeader(udpMsg.RemoteAddr, dstAddr, proxyProtocolVersion)
if err == nil {
// Prepend proxy protocol header to the UDP payload

View File

@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/fatedier/frp/client"
"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) (*client.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) (*client.Proxy
if err != nil {
return nil, err
}
allStatus := make(client.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) (*client.Proxy
return nil, fmt.Errorf("no proxy status found")
}
func (c *Client) GetAllProxyStatus(ctx context.Context) (client.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) (client.StatusResp, erro
if err != nil {
return nil, err
}
allStatus := make(client.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

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

57
pkg/util/http/context.go Normal file
View File

@@ -0,0 +1,57 @@
// 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 (
"encoding/json"
"io"
"net/http"
"github.com/gorilla/mux"
)
type Context struct {
Req *http.Request
Resp http.ResponseWriter
vars map[string]string
}
func NewContext(w http.ResponseWriter, r *http.Request) *Context {
return &Context{
Req: r,
Resp: w,
vars: mux.Vars(r),
}
}
func (c *Context) Param(key string) string {
return c.vars[key]
}
func (c *Context) Query(key string) string {
return c.Req.URL.Query().Get(key)
}
func (c *Context) BindJSON(obj any) error {
body, err := io.ReadAll(c.Req.Body)
if err != nil {
return err
}
return json.Unmarshal(body, obj)
}
func (c *Context) Body() ([]byte, error) {
return io.ReadAll(c.Req.Body)
}

33
pkg/util/http/error.go Normal file
View File

@@ -0,0 +1,33 @@
// 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 "fmt"
type Error struct {
Code int
Err error
}
func (e *Error) Error() string {
return e.Err.Error()
}
func NewError(code int, msg string) *Error {
return &Error{
Code: code,
Err: fmt.Errorf("%s", msg),
}
}

66
pkg/util/http/handler.go Normal file
View File

@@ -0,0 +1,66 @@
// 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 (
"encoding/json"
"net/http"
"github.com/fatedier/frp/pkg/util/log"
)
type GeneralResponse struct {
Code int
Msg string
}
// APIHandler is a handler function that returns a response object or an error.
type APIHandler func(ctx *Context) (any, error)
// MakeHTTPHandlerFunc turns a normal APIHandler into a http.HandlerFunc.
func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := NewContext(w, r)
res, err := handler(ctx)
if err != nil {
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
code := http.StatusInternalServerError
if e, ok := err.(*Error); ok {
code = e.Code
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(GeneralResponse{Code: code, Msg: err.Error()})
return
}
if res == nil {
w.WriteHeader(http.StatusOK)
return
}
switch v := res.(type) {
case []byte:
_, _ = w.Write(v)
case string:
_, _ = w.Write([]byte(v))
default:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
}
}

View File

@@ -0,0 +1,40 @@
// 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 (
"net/http"
"github.com/fatedier/frp/pkg/util/log"
)
type responseWriter struct {
http.ResponseWriter
code int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.code = code
rw.ResponseWriter.WriteHeader(code)
}
func NewRequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Infof("http request: [%s]", r.URL.Path)
rw := &responseWriter{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(rw, r)
log.Infof("http response [%s]: code [%d]", r.URL.Path, rw.code)
})
}

View File

@@ -134,3 +134,12 @@ func RandomSleep(duration time.Duration, minRatio, maxRatio float64) time.Durati
func ConstantTimeEqString(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// ClonePtr returns a pointer to a copied value. If v is nil, it returns nil.
func ClonePtr[T any](v *T) *T {
if v == nil {
return nil
}
out := *v
return &out
}

View File

@@ -41,3 +41,16 @@ func TestParseRangeNumbers(t *testing.T) {
_, err = ParseRangeNumbers("3-a")
require.Error(err)
}
func TestClonePtr(t *testing.T) {
require := require.New(t)
var nilInt *int
require.Nil(ClonePtr(nilInt))
v := 42
cloned := ClonePtr(&v)
require.NotNil(cloned)
require.Equal(v, *cloned)
require.NotSame(&v, cloned)
}

View File

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

View File

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

388
server/api/controller.go Normal file
View File

@@ -0,0 +1,388 @@
// 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/http"
"slices"
"strings"
"time"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
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/proxy"
"github.com/fatedier/frp/server/registry"
)
type Controller struct {
// dependencies
serverCfg *v1.ServerConfig
clientRegistry *registry.ClientRegistry
pxyManager ProxyManager
}
type ProxyManager interface {
GetByName(name string) (proxy.Proxy, bool)
}
func NewController(
serverCfg *v1.ServerConfig,
clientRegistry *registry.ClientRegistry,
pxyManager ProxyManager,
) *Controller {
return &Controller{
serverCfg: serverCfg,
clientRegistry: clientRegistry,
pxyManager: pxyManager,
}
}
// /api/serverinfo
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
serverStats := mem.StatsCollector.GetServer()
svrResp := ServerInfoResp{
Version: version.Full(),
BindPort: c.serverCfg.BindPort,
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
VhostHTTPSPort: c.serverCfg.VhostHTTPSPort,
TCPMuxHTTPConnectPort: c.serverCfg.TCPMuxHTTPConnectPort,
KCPBindPort: c.serverCfg.KCPBindPort,
QUICBindPort: c.serverCfg.QUICBindPort,
SubdomainHost: c.serverCfg.SubDomainHost,
MaxPoolCount: c.serverCfg.Transport.MaxPoolCount,
MaxPortsPerClient: c.serverCfg.MaxPortsPerClient,
HeartBeatTimeout: c.serverCfg.Transport.HeartbeatTimeout,
AllowPortsStr: types.PortsRangeSlice(c.serverCfg.AllowPorts).String(),
TLSForce: c.serverCfg.Transport.TLS.Force,
TotalTrafficIn: serverStats.TotalTrafficIn,
TotalTrafficOut: serverStats.TotalTrafficOut,
CurConns: serverStats.CurConns,
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
}
// /api/clients
func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
userFilter := ctx.Query("user")
clientIDFilter := ctx.Query("clientId")
runIDFilter := ctx.Query("runId")
statusFilter := strings.ToLower(ctx.Query("status"))
records := c.clientRegistry.List()
items := make([]ClientInfoResp, 0, len(records))
for _, info := range records {
if userFilter != "" && info.User != userFilter {
continue
}
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
continue
}
if runIDFilter != "" && info.RunID != runIDFilter {
continue
}
if !matchStatusFilter(info.Online, statusFilter) {
continue
}
items = append(items, buildClientInfoResp(info))
}
slices.SortFunc(items, func(a, b ClientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
return v
}
return cmp.Compare(a.Key, b.Key)
})
return items, nil
}
// /api/clients/{key}
func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
key := ctx.Param("key")
if key == "" {
return nil, fmt.Errorf("missing client key")
}
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
info, ok := c.clientRegistry.GetByKey(key)
if !ok {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
}
return buildClientInfoResp(info), nil
}
// /api/proxy/:type
func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type")
proxyInfoResp := GetProxyInfoResp{}
proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
return cmp.Compare(a.Name, b.Name)
})
return proxyInfoResp, nil
}
// /api/proxy/:type/:name
func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {
proxyType := ctx.Param("type")
name := ctx.Param("name")
proxyStatsResp, code, msg := c.getProxyStatsByTypeAndName(proxyType, name)
if code != 200 {
return nil, httppkg.NewError(code, msg)
}
return proxyStatsResp, nil
}
// /api/traffic/:name
func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
trafficResp := GetProxyTrafficResp{}
trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
if proxyTrafficInfo == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn
trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut
return trafficResp, nil
}
// /api/proxies/:name
func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
ps := mem.StatsCollector.GetProxyByName(name)
if ps == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
proxyInfo := GetProxyStatsResp{
Name: ps.Name,
User: ps.User,
ClientID: ps.ClientID,
TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime,
LastCloseTime: ps.LastCloseTime,
}
if pxy, ok := c.pxyManager.GetByName(name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", name, err)
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err)
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
}
return proxyInfo, nil
}
// DELETE /api/proxies?status=offline
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
status := ctx.Query("status")
if status != "offline" {
return nil, httppkg.NewError(http.StatusBadRequest, "status only support offline")
}
cleared, total := mem.StatsCollector.ClearOfflineProxies()
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
return httppkg.GeneralResponse{Code: 200, Msg: "success"}, nil
}
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats {
proxyInfo := &ProxyStatsInfo{
User: ps.User,
ClientID: ps.ClientID,
}
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
}
proxyInfo.Name = ps.Name
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
proxyInfo.CurConns = ps.CurConns
proxyInfo.LastStartTime = ps.LastStartTime
proxyInfo.LastCloseTime = ps.LastCloseTime
proxyInfos = append(proxyInfos, proxyInfo)
}
return
}
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 {
code = 404
msg = "no proxy info found"
} else {
proxyInfo.User = ps.User
proxyInfo.ClientID = ps.ClientID
if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
}
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
proxyInfo.CurConns = ps.CurConns
proxyInfo.LastStartTime = ps.LastStartTime
proxyInfo.LastCloseTime = ps.LastCloseTime
code = 200
}
return
}
func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
resp := ClientInfoResp{
Key: info.Key,
User: info.User,
ClientID: info.ClientID(),
RunID: info.RunID,
Version: info.Version,
Hostname: info.Hostname,
ClientIP: info.IP,
FirstConnectedAt: toUnix(info.FirstConnectedAt),
LastConnectedAt: toUnix(info.LastConnectedAt),
Online: info.Online,
}
if !info.DisconnectedAt.IsZero() {
resp.DisconnectedAt = info.DisconnectedAt.Unix()
}
return resp
}
func toUnix(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
func matchStatusFilter(online bool, filter string) bool {
switch strings.ToLower(filter) {
case "", "all":
return true
case "online":
return online
case "offline":
return !online
default:
return true
}
}
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
}
}

135
server/api/types.go Normal file
View File

@@ -0,0 +1,135 @@
// 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 (
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type ServerInfoResp struct {
Version string `json:"version"`
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartBeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr,omitempty"`
TLSForce bool `json:"tlsForce,omitempty"`
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
type ClientInfoResp struct {
Key string `json:"key"`
User string `json:"user"`
ClientID string `json:"clientID"`
RunID string `json:"runID"`
Version string `json:"version,omitempty"`
Hostname string `json:"hostname"`
ClientIP string `json:"clientIP,omitempty"`
FirstConnectedAt int64 `json:"firstConnectedAt"`
LastConnectedAt int64 `json:"lastConnectedAt"`
DisconnectedAt int64 `json:"disconnectedAt,omitempty"`
Online bool `json:"online"`
}
type BaseOutConf struct {
v1.ProxyBaseConfig
}
type TCPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type TCPMuxOutConf struct {
BaseOutConf
v1.DomainConfig
Multiplexer string `json:"multiplexer"`
RouteByHTTPUser string `json:"routeByHTTPUser"`
}
type UDPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type HTTPOutConf struct {
BaseOutConf
v1.DomainConfig
Locations []string `json:"locations"`
HostHeaderRewrite string `json:"hostHeaderRewrite"`
}
type HTTPSOutConf struct {
BaseOutConf
v1.DomainConfig
}
type STCPOutConf struct {
BaseOutConf
}
type XTCPOutConf struct {
BaseOutConf
}
// Get proxy info.
type ProxyStatsInfo struct {
Name string `json:"name"`
Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
type GetProxyInfoResp struct {
Proxies []*ProxyStatsInfo `json:"proxies"`
}
// Get proxy info by name.
type GetProxyStatsResp struct {
Name string `json:"name"`
Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
// /api/traffic/:name
type GetProxyTrafficResp struct {
Name string `json:"name"`
TrafficIn []int64 `json:"trafficIn"`
TrafficOut []int64 `json:"trafficOut"`
}

View File

@@ -40,6 +40,7 @@ import (
"github.com/fatedier/frp/server/controller"
"github.com/fatedier/frp/server/metrics"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
)
type ControlManager struct {
@@ -147,6 +148,8 @@ type Control struct {
// Server configuration information
serverCfg *v1.ServerConfig
clientRegistry *registry.ClientRegistry
xl *xlog.Logger
ctx context.Context
doneCh chan struct{}
@@ -358,6 +361,7 @@ func (ctl *Control) worker() {
}
metrics.Server.CloseClient()
ctl.clientRegistry.MarkOfflineByRunID(ctl.runID)
xl.Infof("client exit success")
close(ctl.doneCh)
}
@@ -401,7 +405,11 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
} else {
resp.RemoteAddr = remoteAddr
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType)
clientID := ctl.loginMsg.ClientID
if clientID == "" {
clientID = ctl.loginMsg.RunID
}
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID)
}
_ = ctl.msgDispatcher.Send(resp)
}

View File

@@ -1,406 +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 (
"cmp"
"encoding/json"
"net/http"
"slices"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/version"
)
type GeneralResponse struct {
Code int
Msg string
}
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
helper.Router.HandleFunc("/healthz", svr.healthz)
subRouter := helper.Router.NewRoute().Subrouter()
subRouter.Use(helper.AuthMiddleware.Middleware)
// metrics
if svr.cfg.EnablePrometheus {
subRouter.Handle("/metrics", promhttp.Handler())
}
// apis
subRouter.HandleFunc("/api/serverinfo", svr.apiServerInfo).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", svr.apiProxyByType).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", svr.apiProxyByTypeAndName).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", svr.apiProxyTraffic).Methods("GET")
subRouter.HandleFunc("/api/proxies", svr.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)
})
}
type serverInfoResp struct {
Version string `json:"version"`
BindPort int `json:"bindPort"`
VhostHTTPPort int `json:"vhostHTTPPort"`
VhostHTTPSPort int `json:"vhostHTTPSPort"`
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
KCPBindPort int `json:"kcpBindPort"`
QUICBindPort int `json:"quicBindPort"`
SubdomainHost string `json:"subdomainHost"`
MaxPoolCount int64 `json:"maxPoolCount"`
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
HeartBeatTimeout int64 `json:"heartbeatTimeout"`
AllowPortsStr string `json:"allowPortsStr,omitempty"`
TLSForce bool `json:"tlsForce,omitempty"`
TotalTrafficIn int64 `json:"totalTrafficIn"`
TotalTrafficOut int64 `json:"totalTrafficOut"`
CurConns int64 `json:"curConns"`
ClientCounts int64 `json:"clientCounts"`
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
// /healthz
func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}
// /api/serverinfo
func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.Path)
serverStats := mem.StatsCollector.GetServer()
svrResp := serverInfoResp{
Version: version.Full(),
BindPort: svr.cfg.BindPort,
VhostHTTPPort: svr.cfg.VhostHTTPPort,
VhostHTTPSPort: svr.cfg.VhostHTTPSPort,
TCPMuxHTTPConnectPort: svr.cfg.TCPMuxHTTPConnectPort,
KCPBindPort: svr.cfg.KCPBindPort,
QUICBindPort: svr.cfg.QUICBindPort,
SubdomainHost: svr.cfg.SubDomainHost,
MaxPoolCount: svr.cfg.Transport.MaxPoolCount,
MaxPortsPerClient: svr.cfg.MaxPortsPerClient,
HeartBeatTimeout: svr.cfg.Transport.HeartbeatTimeout,
AllowPortsStr: types.PortsRangeSlice(svr.cfg.AllowPorts).String(),
TLSForce: svr.cfg.Transport.TLS.Force,
TotalTrafficIn: serverStats.TotalTrafficIn,
TotalTrafficOut: serverStats.TotalTrafficOut,
CurConns: serverStats.CurConns,
ClientCounts: serverStats.ClientCounts,
ProxyTypeCounts: serverStats.ProxyTypeCounts,
}
buf, _ := json.Marshal(&svrResp)
res.Msg = string(buf)
}
type BaseOutConf struct {
v1.ProxyBaseConfig
}
type TCPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type TCPMuxOutConf struct {
BaseOutConf
v1.DomainConfig
Multiplexer string `json:"multiplexer"`
RouteByHTTPUser string `json:"routeByHTTPUser"`
}
type UDPOutConf struct {
BaseOutConf
RemotePort int `json:"remotePort"`
}
type HTTPOutConf struct {
BaseOutConf
v1.DomainConfig
Locations []string `json:"locations"`
HostHeaderRewrite string `json:"hostHeaderRewrite"`
}
type HTTPSOutConf struct {
BaseOutConf
v1.DomainConfig
}
type STCPOutConf struct {
BaseOutConf
}
type XTCPOutConf struct {
BaseOutConf
}
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
}
}
// Get proxy info.
type ProxyStatsInfo struct {
Name string `json:"name"`
Conf any `json:"conf"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
type GetProxyInfoResp struct {
Proxies []*ProxyStatsInfo `json:"proxies"`
}
// /api/proxy/:type
func (svr *Service) apiProxyByType(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
params := mux.Vars(r)
proxyType := params["type"]
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.Path)
proxyInfoResp := GetProxyInfoResp{}
proxyInfoResp.Proxies = svr.getProxyStatsByType(proxyType)
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
return cmp.Compare(a.Name, b.Name)
})
buf, _ := json.Marshal(&proxyInfoResp)
res.Msg = string(buf)
}
func (svr *Service) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats {
proxyInfo := &ProxyStatsInfo{}
if pxy, ok := svr.pxyManager.GetByName(ps.Name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
continue
}
proxyInfo.Status = "online"
if pxy.GetLoginMsg() != nil {
proxyInfo.ClientVersion = pxy.GetLoginMsg().Version
}
} else {
proxyInfo.Status = "offline"
}
proxyInfo.Name = ps.Name
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
proxyInfo.CurConns = ps.CurConns
proxyInfo.LastStartTime = ps.LastStartTime
proxyInfo.LastCloseTime = ps.LastCloseTime
proxyInfos = append(proxyInfos, proxyInfo)
}
return
}
// Get proxy info by name.
type GetProxyStatsResp struct {
Name string `json:"name"`
Conf any `json:"conf"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"`
LastStartTime string `json:"lastStartTime"`
LastCloseTime string `json:"lastCloseTime"`
Status string `json:"status"`
}
// /api/proxy/:type/:name
func (svr *Service) apiProxyByTypeAndName(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
params := mux.Vars(r)
proxyType := params["type"]
name := params["name"]
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.Path)
var proxyStatsResp GetProxyStatsResp
proxyStatsResp, res.Code, res.Msg = svr.getProxyStatsByTypeAndName(proxyType, name)
if res.Code != 200 {
return
}
buf, _ := json.Marshal(&proxyStatsResp)
res.Msg = string(buf)
}
func (svr *Service) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
proxyInfo.Name = proxyName
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
if ps == nil {
code = 404
msg = "no proxy info found"
} else {
if pxy, ok := svr.pxyManager.GetByName(proxyName); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
code = 400
msg = "parse conf error"
return
}
proxyInfo.Status = "online"
} else {
proxyInfo.Status = "offline"
}
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
proxyInfo.CurConns = ps.CurConns
proxyInfo.LastStartTime = ps.LastStartTime
proxyInfo.LastCloseTime = ps.LastCloseTime
code = 200
}
return
}
// /api/traffic/:name
type GetProxyTrafficResp struct {
Name string `json:"name"`
TrafficIn []int64 `json:"trafficIn"`
TrafficOut []int64 `json:"trafficOut"`
}
func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
params := mux.Vars(r)
name := params["name"]
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.Path)
trafficResp := GetProxyTrafficResp{}
trafficResp.Name = name
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
if proxyTrafficInfo == nil {
res.Code = 404
res.Msg = "no proxy info found"
return
}
trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn
trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut
buf, _ := json.Marshal(&trafficResp)
res.Msg = string(buf)
}
// DELETE /api/proxies?status=offline
func (svr *Service) deleteProxies(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
log.Infof("http request: [%s]", r.URL.Path)
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
status := r.URL.Query().Get("status")
if status != "offline" {
res.Code = 400
res.Msg = "status only support offline"
return
}
cleared, total := mem.StatsCollector.ClearOfflineProxies()
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
}

View File

@@ -7,7 +7,7 @@ import (
type ServerMetrics interface {
NewClient()
CloseClient()
NewProxy(name string, proxyType string)
NewProxy(name string, proxyType string, user string, clientID string)
CloseProxy(name string, proxyType string)
OpenConnection(name string, proxyType string)
CloseConnection(name string, proxyType string)
@@ -27,11 +27,11 @@ func Register(m ServerMetrics) {
type noopServerMetrics struct{}
func (noopServerMetrics) NewClient() {}
func (noopServerMetrics) CloseClient() {}
func (noopServerMetrics) NewProxy(string, string) {}
func (noopServerMetrics) CloseProxy(string, string) {}
func (noopServerMetrics) OpenConnection(string, string) {}
func (noopServerMetrics) CloseConnection(string, string) {}
func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
func (noopServerMetrics) AddTrafficOut(string, string, int64) {}
func (noopServerMetrics) NewClient() {}
func (noopServerMetrics) CloseClient() {}
func (noopServerMetrics) NewProxy(string, string, string, string) {}
func (noopServerMetrics) CloseProxy(string, string) {}
func (noopServerMetrics) OpenConnection(string, string) {}
func (noopServerMetrics) CloseConnection(string, string) {}
func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
func (noopServerMetrics) AddTrafficOut(string, string, int64) {}

165
server/registry/registry.go Normal file
View File

@@ -0,0 +1,165 @@
// 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 registry
import (
"fmt"
"sync"
"time"
)
// ClientInfo captures metadata about a connected frpc instance.
type ClientInfo struct {
Key string
User string
RawClientID string
RunID string
Hostname string
IP string
Version string
FirstConnectedAt time.Time
LastConnectedAt time.Time
DisconnectedAt time.Time
Online bool
}
// ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (runID fallback when raw clientID is empty).
// Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records.
type ClientRegistry struct {
mu sync.RWMutex
clients map[string]*ClientInfo
runIndex map[string]string
}
func NewClientRegistry() *ClientRegistry {
return &ClientRegistry{
clients: make(map[string]*ClientInfo),
runIndex: make(map[string]string),
}
}
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, version, remoteAddr string) (key string, conflict bool) {
if runID == "" {
return "", false
}
effectiveID := rawClientID
if effectiveID == "" {
effectiveID = runID
}
key = cr.composeClientKey(user, effectiveID)
enforceUnique := rawClientID != ""
now := time.Now()
cr.mu.Lock()
defer cr.mu.Unlock()
info, exists := cr.clients[key]
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
return key, true
}
if !exists {
info = &ClientInfo{
Key: key,
User: user,
FirstConnectedAt: now,
}
cr.clients[key] = info
} else if info.RunID != "" {
delete(cr.runIndex, info.RunID)
}
info.RawClientID = rawClientID
info.RunID = runID
info.Hostname = hostname
info.IP = remoteAddr
info.Version = version
if info.FirstConnectedAt.IsZero() {
info.FirstConnectedAt = now
}
info.LastConnectedAt = now
info.DisconnectedAt = time.Time{}
info.Online = true
cr.runIndex[runID] = key
return key, false
}
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
cr.mu.Lock()
defer cr.mu.Unlock()
key, ok := cr.runIndex[runID]
if !ok {
return
}
if info, ok := cr.clients[key]; ok && info.RunID == runID {
if info.RawClientID == "" {
delete(cr.clients, key)
} else {
info.RunID = ""
info.Online = false
now := time.Now()
info.DisconnectedAt = now
}
}
delete(cr.runIndex, runID)
}
// List returns a snapshot of all known clients.
func (cr *ClientRegistry) List() []ClientInfo {
cr.mu.RLock()
defer cr.mu.RUnlock()
result := make([]ClientInfo, 0, len(cr.clients))
for _, info := range cr.clients {
result = append(result, *info)
}
return result
}
// GetByKey retrieves a client by its composite key ({user}.{clientID} with runID fallback).
func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {
cr.mu.RLock()
defer cr.mu.RUnlock()
info, ok := cr.clients[key]
if !ok {
return ClientInfo{}, false
}
return *info, true
}
// ClientID returns the resolved client identifier for external use.
func (info ClientInfo) ClientID() string {
if info.RawClientID != "" {
return info.RawClientID
}
return info.RunID
}
func (cr *ClientRegistry) composeClientKey(user, id string) string {
switch {
case user == "":
return id
case id == "":
return user
default:
return fmt.Sprintf("%s.%s", user, id)
}
}

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,11 +48,13 @@ 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"
"github.com/fatedier/frp/server/ports"
"github.com/fatedier/frp/server/proxy"
"github.com/fatedier/frp/server/registry"
"github.com/fatedier/frp/server/visitor"
)
@@ -96,6 +99,9 @@ type Service struct {
// Manage all controllers
ctlManager *ControlManager
// Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty).
clientRegistry *registry.ClientRegistry
// Manage all proxies
pxyManager *proxy.Manager
@@ -155,9 +161,10 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
}
svr := &Service{
ctlManager: NewControlManager(),
pxyManager: proxy.NewManager(),
pluginManager: plugin.NewManager(),
ctlManager: NewControlManager(),
clientRegistry: registry.NewClientRegistry(),
pxyManager: proxy.NewManager(),
pluginManager: plugin.NewManager(),
rc: &controller.ResourceController{
VisitorManager: visitor.NewManager(),
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
@@ -606,10 +613,23 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
// don't return detailed errors to client
return fmt.Errorf("unexpected error when creating new controller")
}
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
oldCtl.WaitClosed()
}
remoteAddr := ctlConn.RemoteAddr().String()
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
remoteAddr = host
}
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Version, remoteAddr)
if conflict {
svr.ctlManager.Del(loginMsg.RunID, ctl)
ctl.Close()
return fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
}
ctl.clientRegistry = svr.clientRegistry
ctl.Start()
// for statistics
@@ -670,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

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

View File

@@ -1,30 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier',
],
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'vue/multi-word-component-names': [
'error',
{
ignores: ['Overview'],
},
],
},
}

View File

@@ -1,6 +1,9 @@
.PHONY: dist build preview lint
.PHONY: dist install build preview lint
build:
install:
@npm install
build: install
@npm run build
dev:

View File

@@ -7,18 +7,30 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ClientConfigure: typeof import('./src/components/ClientConfigure.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
Overview: typeof import('./src/components/Overview.vue')['default']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StatCard: typeof import('./src/components/StatCard.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -1,3 +1,5 @@
//go:build !noweb
package frpc
import (
@@ -6,9 +8,9 @@ import (
"github.com/fatedier/frp/assets"
)
//go:embed static/*
var content embed.FS
//go:embed dist
var EmbedFS embed.FS
func init() {
assets.Register(content)
assets.Register(EmbedFS)
}

3
web/frpc/embed_stub.go Normal file
View File

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

36
web/frpc/eslint.config.js Normal file
View File

@@ -0,0 +1,36 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
...pluginVue.configs['flat/essential'],
...vueTsEslintConfig(),
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'vue/multi-word-component-names': [
'error',
{
ignores: ['Overview'],
},
],
},
},
skipFormatting,
]

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<title>frp client admin UI</title>
<title>frp client</title>
</head>
<body>

Some files were not shown because too many files have changed in this diff Show More