Compare commits

...

41 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
7993225be1 Initial plan 2026-03-05 17:33:24 +00:00
fatedier
17b27d8d96 pkg/msg: change UDPPacket.Content from string to []byte to avoid redundant base64 encode/decode 2026-03-06 01:25:52 +08:00
fatedier
541878af4d docs: update release notes for config parsing error improvements (#5196) 2026-03-05 00:00:46 +08:00
fatedier
b7435967b0 pkg/config: fix line numbers shown incorrectly for TOML type mismatch errors (#5195) 2026-03-04 20:53:22 +08:00
fatedier
774478d071 pkg/config: improve error messages with line number details for config parsing (#5194)
When frpc verify encounters config errors, the error messages now include
line/column information to help users locate problems:

- TOML syntax errors: report line and column from the TOML parser
  (e.g., "toml: line 4, column 11: expected character ]")
- Type mismatch errors: report the field name and approximate line number
  (e.g., "line 3: field \"proxies\": cannot unmarshal string into ...")
- File format detection: use file extension to determine format, preventing
  silent fallthrough from TOML to YAML parser which produced confusing errors

Previously, a TOML file with syntax errors would silently fall through to the
YAML parser, which would misinterpret the content and produce unhelpful errors
like "json: cannot unmarshal string into Go value of type v1.ClientConfig".

https://claude.ai/code/session_017HWLfcXS3U2hLoy4dsg8Nv

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-04 19:27:30 +08:00
fatedier
fbeb6ca43a refactor: restructure API packages into client/http and server/http with typed proxy/visitor models (#5193) 2026-03-04 17:38:43 +08:00
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
fatedier
33428ab538 add e2e tests for exec-based token source (#5111) 2026-01-04 14:45:51 +08:00
fatedier
ef96481f58 update version and release notes (#5106) 2025-12-25 10:15:40 +08:00
fatedier
7526d7a69a refactor: separate auth config from runtime and defer token resolution (#5105) 2025-12-25 00:53:08 +08:00
fatedier
2bdf25bae6 rotate gold sponsor order periodically (#5094) 2025-12-11 12:48:08 +08:00
fatedier
0fe8f7a0b6 refactor: reorganize security policy into dedicated packag (#5088) 2025-12-05 16:26:09 +08:00
fatedier
2e2802ea13 refactor: use MessageSender interface for message transporter (#5083) 2025-12-02 11:22:48 +08:00
fatedier
c3821202b1 docs: remove zsxq section (#5077) 2025-11-26 23:55:54 +08:00
fatedier
15fd19a16d fix lint (#5068) 2025-11-18 01:11:44 +08:00
Krzysztof Bogacki
66973a03db Add exec value source type (#5050)
* config: introduce ExecSource value source

* auth: introduce OidcTokenSourceAuthProvider

* auth: use OidcTokenSourceAuthProvider if tokenSource config is present on the client

* cmd: allow exec token source only if CLI flag was passed
2025-11-18 00:20:21 +08:00
fatedier
f736d171ac rotate gold sponsor order periodically (#5067) 2025-11-18 00:09:37 +08:00
fatedier
b27b846971 config: add enabled field for individual proxy and visitor (#5048) 2025-11-06 14:05:03 +08:00
fatedier
e025843d3c vnet: add exponential backoff for failed reconnections (#5035) 2025-10-29 01:08:48 +08:00
fatedier
a75320ef2f update quic-go dependency from v0.53.0 to v0.55.0 (#5033) 2025-10-28 17:52:34 +08:00
fatedier
1cf325bb0c https: add load balancing group support (#5032) 2025-10-28 17:37:18 +08:00
fatedier
469097a549 update sponsor pic (#5031) 2025-10-28 16:08:29 +08:00
fatedier
2def23bb0b update sponsor (#5030) 2025-10-28 15:44:03 +08:00
Zachary Whaley
ee3cc4b14e Fix CloseNotifyConn.Close function (#5022)
The CloseNotifyConn.Close() function calls itself if the closeFlag is equal to 0 which would mean it would immediately swap the closeFlag value to 1 and never call closeFn.  It is unclear what the intent of this call to cc.Close() was but I assume it was meant to be a call to close the Conn object instead.
2025-10-17 10:53:43 +08:00
fatedier
e382676659 update README (#5001) 2025-09-26 12:26:08 +08:00
224 changed files with 33295 additions and 8368 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,12 +33,7 @@ linters:
disabled-checks:
- exitAfterDefer
gosec:
excludes:
- G401
- G402
- G404
- G501
- G115
excludes: ["G115", "G117", "G204", "G401", "G402", "G404", "G501", "G703", "G704", "G705"]
severity: low
confidence: low
govet:
@@ -76,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,22 +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://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp" target="_blank">
<b>Recall.ai - API for meeting recordings</b><br>
<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>
<sup>If you're looking for a meeting recording API, consider checking out Recall.ai, an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.</sup>
</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>
<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">
@@ -36,13 +40,7 @@ frp is an open source project with its ongoing development made possible entirel
<b>The complete IDE crafted for professional Go developers</b>
</a>
</p>
<p align="center">
<a href="https://github.com/daytonaio/daytona" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_daytona.png">
<br>
<b>Secure and Elastic Infrastructure for Running Your AI-Generated Code</b>
</a>
</p>
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
@@ -803,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,22 +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://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp" target="_blank">
<b>Recall.ai - API for meeting recordings</b><br>
<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>
<sup>If you're looking for a meeting recording API, consider checking out Recall.ai, an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.</sup>
</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>
<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">
@@ -38,13 +42,7 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<b>The complete IDE crafted for professional Go developers</b>
</a>
</p>
<p align="center">
<a href="https://github.com/daytonaio/daytona" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_daytona.png">
<br>
<b>Secure and Elastic Infrastructure for Running Your AI-Generated Code</b>
</a>
</p>
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
@@ -126,9 +124,3 @@ frp 是一个免费且开源的项目,我们欢迎任何人为其开发和进
国内用户可以通过 [爱发电](https://afdian.com/a/fatedier) 赞助我们。
企业赞助者可以将贵公司的 Logo 以及链接放置在项目 README 文件中。
### 知识星球
如果您想了解更多 frp 相关技术以及更新详解,或者寻求任何 frp 使用方面的帮助,都可以通过微信扫描下方的二维码付费加入知识星球的官方社群:
![zsxq](/doc/pic/zsxq.jpg)

View File

@@ -1,5 +1,9 @@
## Features
* Add NAT traversal configuration options for XTCP proxies and visitors. Support disabling assisted addresses to avoid using slow VPN connections during NAT hole punching.
* Enhanced OIDC client configuration with support for custom TLS certificate verification and proxy settings. Added `trustedCaFile`, `insecureSkipVerify`, and `proxyURL` options for OIDC token endpoint connections.
* Added detailed Prometheus metrics with `proxy_counts_detailed` metric that includes both proxy type and proxy name labels, enabling monitoring of individual proxy connections instead of just aggregate counts.
* 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
* Kept proxy/visitor names as raw config names during completion; moved user-prefix handling to explicit wire-level naming logic.
* Added `noweb` build tag to allow compiling without frontend assets. `make build` now auto-detects missing `web/*/dist` directories and skips embedding, so a fresh clone can build without running `make web` first. The dashboard gracefully returns 404 when assets are not embedded.
* Improved config parsing errors: for `.toml` files, syntax errors now return immediately with parser position details (line/column when available) instead of falling through to YAML/JSON parsing, and TOML type mismatches report field-level errors without misleading line numbers.

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

@@ -1,262 +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 client
import (
"cmp"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"slices"
"strconv"
"time"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/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)
subRouter := helper.Router.NewRoute().Subrouter()
subRouter.Use(helper.AuthMiddleware.Middleware)
// 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))),
).Methods("GET")
subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
})
}
// /healthz
func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}
// 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); 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")
}
// 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)
}()
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
}
}

85
client/api_router.go Normal file
View File

@@ -0,0 +1,85 @@
// 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 client
import (
"net/http"
adminapi "github.com/fatedier/frp/client/http"
"github.com/fatedier/frp/client/proxy"
httppkg "github.com/fatedier/frp/pkg/util/http"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
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)
if svr.storeSource != nil {
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreProxy)).Methods(http.MethodPost)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreProxy)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreProxy)).Methods(http.MethodPut)
subRouter.HandleFunc("/api/store/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreProxy)).Methods(http.MethodDelete)
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreVisitors)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/visitors", httppkg.MakeHTTPHandlerFunc(apiController.CreateStoreVisitor)).Methods(http.MethodPost)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.GetStoreVisitor)).Methods(http.MethodGet)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.UpdateStoreVisitor)).Methods(http.MethodPut)
subRouter.HandleFunc("/api/store/visitors/{name}", httppkg.MakeHTTPHandlerFunc(apiController.DeleteStoreVisitor)).Methods(http.MethodDelete)
}
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
).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(http.StatusOK)
}
func newAPIController(svr *Service) *adminapi.Controller {
manager := newServiceConfigManager(svr)
return adminapi.NewController(adminapi.ControllerParams{
ServerAddr: svr.common.ServerAddr,
Manager: manager,
})
}
// getAllProxyStatus returns all proxy statuses.
func (svr *Service) getAllProxyStatus() []*proxy.WorkingStatus {
svr.ctlMu.RLock()
ctl := svr.ctl
svr.ctlMu.RUnlock()
if ctl == nil {
return nil
}
return ctl.pm.GetAllProxyStatus()
}

422
client/config_manager.go Normal file
View File

@@ -0,0 +1,422 @@
package client
import (
"errors"
"fmt"
"os"
"time"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/util/log"
)
type serviceConfigManager struct {
svr *Service
}
func newServiceConfigManager(svr *Service) configmgmt.ConfigManager {
return &serviceConfigManager{svr: svr}
}
func (m *serviceConfigManager) ReloadFromFile(strict bool) error {
if m.svr.configFilePath == "" {
return fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument)
}
result, err := config.LoadClientConfigResult(m.svr.configFilePath, strict)
if err != nil {
return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
}
proxyCfgsForValidation, visitorCfgsForValidation := config.FilterClientConfigurers(
result.Common,
result.Proxies,
result.Visitors,
)
proxyCfgsForValidation = config.CompleteProxyConfigurers(proxyCfgsForValidation)
visitorCfgsForValidation = config.CompleteVisitorConfigurers(visitorCfgsForValidation)
if _, err := validation.ValidateAllClientConfig(result.Common, proxyCfgsForValidation, visitorCfgsForValidation, m.svr.unsafeFeatures); err != nil {
return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil {
return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err)
}
log.Infof("success reload conf")
return nil
}
func (m *serviceConfigManager) ReadConfigFile() (string, error) {
if m.svr.configFilePath == "" {
return "", fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument)
}
content, err := os.ReadFile(m.svr.configFilePath)
if err != nil {
return "", fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
}
return string(content), nil
}
func (m *serviceConfigManager) WriteConfigFile(content []byte) error {
if len(content) == 0 {
return fmt.Errorf("%w: body can't be empty", configmgmt.ErrInvalidArgument)
}
if err := os.WriteFile(m.svr.configFilePath, content, 0o600); err != nil {
return err
}
return nil
}
func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
return m.svr.getAllProxyStatus()
}
func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool {
if name == "" {
return false
}
m.svr.reloadMu.Lock()
storeSource := m.svr.storeSource
m.svr.reloadMu.Unlock()
if storeSource == nil {
return false
}
cfg := storeSource.GetProxy(name)
if cfg == nil {
return false
}
enabled := cfg.GetBaseConfig().Enabled
return enabled == nil || *enabled
}
func (m *serviceConfigManager) StoreEnabled() bool {
m.svr.reloadMu.Lock()
storeSource := m.svr.storeSource
m.svr.reloadMu.Unlock()
return storeSource != nil
}
func (m *serviceConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
storeSource, err := m.storeSourceOrError()
if err != nil {
return nil, err
}
return storeSource.GetAllProxies()
}
func (m *serviceConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {
if name == "" {
return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
}
storeSource, err := m.storeSourceOrError()
if err != nil {
return nil, err
}
cfg := storeSource.GetProxy(name)
if cfg == nil {
return nil, fmt.Errorf("%w: proxy %q", configmgmt.ErrNotFound, name)
}
return cfg, nil
}
func (m *serviceConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
if err := m.validateStoreProxyConfigurer(cfg); err != nil {
return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
name := cfg.GetBaseConfig().Name
persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {
if err := storeSource.AddProxy(cfg); err != nil {
if errors.Is(err, source.ErrAlreadyExists) {
return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
}
return err
}
return nil
})
if err != nil {
return nil, err
}
log.Infof("store: created proxy %q", name)
return persisted, nil
}
func (m *serviceConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
if name == "" {
return nil, fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
}
if cfg == nil {
return nil, fmt.Errorf("%w: invalid proxy config: type is required", configmgmt.ErrInvalidArgument)
}
bodyName := cfg.GetBaseConfig().Name
if bodyName != name {
return nil, fmt.Errorf("%w: proxy name in URL must match name in body", configmgmt.ErrInvalidArgument)
}
if err := m.validateStoreProxyConfigurer(cfg); err != nil {
return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
persisted, err := m.withStoreProxyMutationAndReload(name, func(storeSource *source.StoreSource) error {
if err := storeSource.UpdateProxy(cfg); err != nil {
if errors.Is(err, source.ErrNotFound) {
return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
}
return err
}
return nil
})
if err != nil {
return nil, err
}
log.Infof("store: updated proxy %q", name)
return persisted, nil
}
func (m *serviceConfigManager) DeleteStoreProxy(name string) error {
if name == "" {
return fmt.Errorf("%w: proxy name is required", configmgmt.ErrInvalidArgument)
}
if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
if err := storeSource.RemoveProxy(name); err != nil {
if errors.Is(err, source.ErrNotFound) {
return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
}
return err
}
return nil
}); err != nil {
return err
}
log.Infof("store: deleted proxy %q", name)
return nil
}
func (m *serviceConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {
storeSource, err := m.storeSourceOrError()
if err != nil {
return nil, err
}
return storeSource.GetAllVisitors()
}
func (m *serviceConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {
if name == "" {
return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
}
storeSource, err := m.storeSourceOrError()
if err != nil {
return nil, err
}
cfg := storeSource.GetVisitor(name)
if cfg == nil {
return nil, fmt.Errorf("%w: visitor %q", configmgmt.ErrNotFound, name)
}
return cfg, nil
}
func (m *serviceConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
name := cfg.GetBaseConfig().Name
persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {
if err := storeSource.AddVisitor(cfg); err != nil {
if errors.Is(err, source.ErrAlreadyExists) {
return fmt.Errorf("%w: %v", configmgmt.ErrConflict, err)
}
return err
}
return nil
})
if err != nil {
return nil, err
}
log.Infof("store: created visitor %q", name)
return persisted, nil
}
func (m *serviceConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
if name == "" {
return nil, fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
}
if cfg == nil {
return nil, fmt.Errorf("%w: invalid visitor config: type is required", configmgmt.ErrInvalidArgument)
}
bodyName := cfg.GetBaseConfig().Name
if bodyName != name {
return nil, fmt.Errorf("%w: visitor name in URL must match name in body", configmgmt.ErrInvalidArgument)
}
if err := m.validateStoreVisitorConfigurer(cfg); err != nil {
return nil, fmt.Errorf("%w: validation error: %v", configmgmt.ErrInvalidArgument, err)
}
persisted, err := m.withStoreVisitorMutationAndReload(name, func(storeSource *source.StoreSource) error {
if err := storeSource.UpdateVisitor(cfg); err != nil {
if errors.Is(err, source.ErrNotFound) {
return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
}
return err
}
return nil
})
if err != nil {
return nil, err
}
log.Infof("store: updated visitor %q", name)
return persisted, nil
}
func (m *serviceConfigManager) DeleteStoreVisitor(name string) error {
if name == "" {
return fmt.Errorf("%w: visitor name is required", configmgmt.ErrInvalidArgument)
}
if err := m.withStoreMutationAndReload(func(storeSource *source.StoreSource) error {
if err := storeSource.RemoveVisitor(name); err != nil {
if errors.Is(err, source.ErrNotFound) {
return fmt.Errorf("%w: %v", configmgmt.ErrNotFound, err)
}
return err
}
return nil
}); err != nil {
return err
}
log.Infof("store: deleted visitor %q", name)
return nil
}
func (m *serviceConfigManager) GracefulClose(d time.Duration) {
m.svr.GracefulClose(d)
}
func (m *serviceConfigManager) storeSourceOrError() (*source.StoreSource, error) {
m.svr.reloadMu.Lock()
storeSource := m.svr.storeSource
m.svr.reloadMu.Unlock()
if storeSource == nil {
return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
}
return storeSource, nil
}
func (m *serviceConfigManager) withStoreMutationAndReload(
fn func(storeSource *source.StoreSource) error,
) error {
m.svr.reloadMu.Lock()
defer m.svr.reloadMu.Unlock()
storeSource := m.svr.storeSource
if storeSource == nil {
return fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
}
if err := fn(storeSource); err != nil {
return err
}
if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
return fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
}
return nil
}
func (m *serviceConfigManager) withStoreProxyMutationAndReload(
name string,
fn func(storeSource *source.StoreSource) error,
) (v1.ProxyConfigurer, error) {
m.svr.reloadMu.Lock()
defer m.svr.reloadMu.Unlock()
storeSource := m.svr.storeSource
if storeSource == nil {
return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
}
if err := fn(storeSource); err != nil {
return nil, err
}
if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
}
persisted := storeSource.GetProxy(name)
if persisted == nil {
return nil, fmt.Errorf("%w: proxy %q not found in store after mutation", configmgmt.ErrApplyConfig, name)
}
return persisted.Clone(), nil
}
func (m *serviceConfigManager) withStoreVisitorMutationAndReload(
name string,
fn func(storeSource *source.StoreSource) error,
) (v1.VisitorConfigurer, error) {
m.svr.reloadMu.Lock()
defer m.svr.reloadMu.Unlock()
storeSource := m.svr.storeSource
if storeSource == nil {
return nil, fmt.Errorf("%w: store API is disabled", configmgmt.ErrStoreDisabled)
}
if err := fn(storeSource); err != nil {
return nil, err
}
if err := m.svr.reloadConfigFromSourcesLocked(); err != nil {
return nil, fmt.Errorf("%w: failed to apply config: %v", configmgmt.ErrApplyConfig, err)
}
persisted := storeSource.GetVisitor(name)
if persisted == nil {
return nil, fmt.Errorf("%w: visitor %q not found in store after mutation", configmgmt.ErrApplyConfig, name)
}
return persisted.Clone(), nil
}
func (m *serviceConfigManager) validateStoreProxyConfigurer(cfg v1.ProxyConfigurer) error {
if cfg == nil {
return fmt.Errorf("invalid proxy config")
}
runtimeCfg := cfg.Clone()
if runtimeCfg == nil {
return fmt.Errorf("invalid proxy config")
}
runtimeCfg.Complete()
return validation.ValidateProxyConfigurerForClient(runtimeCfg)
}
func (m *serviceConfigManager) validateStoreVisitorConfigurer(cfg v1.VisitorConfigurer) error {
if cfg == nil {
return fmt.Errorf("invalid visitor config")
}
runtimeCfg := cfg.Clone()
if runtimeCfg == nil {
return fmt.Errorf("invalid visitor config")
}
runtimeCfg.Complete()
return validation.ValidateVisitorConfigurer(runtimeCfg)
}

View File

@@ -0,0 +1,137 @@
package client
import (
"errors"
"path/filepath"
"testing"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
return &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: name,
Type: "tcp",
ProxyBackend: v1.ProxyBackend{
LocalPort: 10080,
},
},
}
}
func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
})
if err != nil {
t.Fatalf("new store source: %v", err)
}
if err := storeSource.AddProxy(newTestRawTCPProxyConfig("p1")); err != nil {
t.Fatalf("seed proxy: %v", err)
}
agg := source.NewAggregator(source.NewConfigSource())
agg.SetStoreSource(storeSource)
mgr := &serviceConfigManager{
svr: &Service{
aggregator: agg,
configSource: agg.ConfigSource(),
storeSource: storeSource,
reloadCommon: &v1.ClientCommonConfig{},
},
}
_, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
if err == nil {
t.Fatal("expected conflict error")
}
if !errors.Is(err, configmgmt.ErrConflict) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestServiceConfigManagerCreateStoreProxyKeepsStoreOnReloadFailure(t *testing.T) {
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
})
if err != nil {
t.Fatalf("new store source: %v", err)
}
mgr := &serviceConfigManager{
svr: &Service{
storeSource: storeSource,
reloadCommon: &v1.ClientCommonConfig{},
},
}
_, err = mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
if err == nil {
t.Fatal("expected apply config error")
}
if !errors.Is(err, configmgmt.ErrApplyConfig) {
t.Fatalf("unexpected error: %v", err)
}
if storeSource.GetProxy("p1") == nil {
t.Fatal("proxy should remain in store after reload failure")
}
}
func TestServiceConfigManagerCreateStoreProxyStoreDisabled(t *testing.T) {
mgr := &serviceConfigManager{
svr: &Service{
reloadCommon: &v1.ClientCommonConfig{},
},
}
_, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("p1"))
if err == nil {
t.Fatal("expected store disabled error")
}
if !errors.Is(err, configmgmt.ErrStoreDisabled) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestServiceConfigManagerCreateStoreProxyDoesNotPersistRuntimeDefaults(t *testing.T) {
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
})
if err != nil {
t.Fatalf("new store source: %v", err)
}
agg := source.NewAggregator(source.NewConfigSource())
agg.SetStoreSource(storeSource)
mgr := &serviceConfigManager{
svr: &Service{
aggregator: agg,
configSource: agg.ConfigSource(),
storeSource: storeSource,
reloadCommon: &v1.ClientCommonConfig{},
},
}
persisted, err := mgr.CreateStoreProxy(newTestRawTCPProxyConfig("raw-proxy"))
if err != nil {
t.Fatalf("create store proxy: %v", err)
}
if persisted == nil {
t.Fatal("expected persisted proxy to be returned")
}
got := storeSource.GetProxy("raw-proxy")
if got == nil {
t.Fatal("proxy not found in store")
}
if got.GetBaseConfig().LocalIP != "" {
t.Fatalf("localIP was persisted with runtime default: %q", got.GetBaseConfig().LocalIP)
}
if got.GetBaseConfig().Transport.BandwidthLimitMode != "" {
t.Fatalf("bandwidthLimitMode was persisted with runtime default: %q", got.GetBaseConfig().Transport.BandwidthLimitMode)
}
}

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) (v1.ProxyConfigurer, error)
UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
DeleteStoreProxy(name string) error
ListStoreVisitors() ([]v1.VisitorConfigurer, error)
GetStoreVisitor(name string) (v1.VisitorConfigurer, error)
CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
DeleteStoreVisitor(name string) error
GracefulClose(d time.Duration)
}

View File

@@ -25,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"
@@ -43,8 +44,8 @@ type SessionContext struct {
Conn net.Conn
// Indicates whether the connection is encrypted.
ConnEncrypted bool
// Sets authentication based on selected method
AuthSetter auth.Setter
// Auth runtime used for login, heartbeats, and encryption.
Auth *auth.ClientAuth
// Connector is used to create new connections, which could be real TCP connections or virtual streams.
Connector Connector
// Virtual net controller
@@ -91,7 +92,7 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
ctl.lastPong.Store(time.Now())
if sessionCtx.ConnEncrypted {
cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, []byte(sessionCtx.Common.Auth.Token))
cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, sessionCtx.Auth.EncryptionKey())
if err != nil {
return nil, err
}
@@ -100,9 +101,9 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn)
}
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel())
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter, sessionCtx.VnetController)
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)
ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
return ctl, nil
@@ -133,7 +134,7 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) {
m := &msg.NewWorkConn{
RunID: ctl.sessionCtx.RunID,
}
if err = ctl.sessionCtx.AuthSetter.SetNewWorkConn(m); err != nil {
if err = ctl.sessionCtx.Auth.Setter.SetNewWorkConn(m); err != nil {
xl.Warnf("error during NewWorkConn authentication: %v", err)
workConn.Close()
return
@@ -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)
}
}
@@ -243,7 +247,7 @@ func (ctl *Control) heartbeatWorker() {
sendHeartBeat := func() (bool, error) {
xl.Debugf("send heartbeat to server")
pingMsg := &msg.Ping{}
if err := ctl.sessionCtx.AuthSetter.SetPing(pingMsg); err != nil {
if err := ctl.sessionCtx.Auth.Setter.SetPing(pingMsg); err != nil {
xl.Warnf("error during ping authentication: %v, skip sending ping message", err)
return false, err
}

395
client/http/controller.go Normal file
View File

@@ -0,0 +1,395 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package http
import (
"cmp"
"errors"
"fmt"
"net"
"net/http"
"slices"
"strconv"
"time"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/client/http/model"
"github.com/fatedier/frp/client/proxy"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/jsonx"
)
// Controller handles HTTP API requests for frpc.
type Controller struct {
serverAddr string
manager configmgmt.ConfigManager
}
// ControllerParams contains parameters for creating an APIController.
type ControllerParams struct {
ServerAddr string
Manager configmgmt.ConfigManager
}
func NewController(params ControllerParams) *Controller {
return &Controller{
serverAddr: params.ServerAddr,
manager: params.Manager,
}
}
func (c *Controller) toHTTPError(err error) error {
if err == nil {
return nil
}
code := http.StatusInternalServerError
switch {
case errors.Is(err, configmgmt.ErrInvalidArgument):
code = http.StatusBadRequest
case errors.Is(err, configmgmt.ErrNotFound), errors.Is(err, configmgmt.ErrStoreDisabled):
code = http.StatusNotFound
case errors.Is(err, configmgmt.ErrConflict):
code = http.StatusConflict
}
return httppkg.NewError(code, err.Error())
}
// Reload handles GET /api/reload
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
strictConfigMode := false
strictStr := ctx.Query("strictConfig")
if strictStr != "" {
strictConfigMode, _ = strconv.ParseBool(strictStr)
}
if err := c.manager.ReloadFromFile(strictConfigMode); err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
}
// Stop handles POST /api/stop
func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
go c.manager.GracefulClose(100 * time.Millisecond)
return nil, nil
}
// Status handles GET /api/status
func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
res := make(model.StatusResp)
ps := c.manager.GetProxyStatus()
if ps == nil {
return res, nil
}
for _, status := range ps {
res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))
}
for _, arrs := range res {
if len(arrs) <= 1 {
continue
}
slices.SortFunc(arrs, func(a, b model.ProxyStatusResp) int {
return cmp.Compare(a.Name, b.Name)
})
}
return res, nil
}
// GetConfig handles GET /api/config
func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {
content, err := c.manager.ReadConfigFile()
if err != nil {
return nil, c.toHTTPError(err)
}
return content, nil
}
// PutConfig handles PUT /api/config
func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err))
}
if len(body) == 0 {
return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty")
}
if err := c.manager.WriteConfigFile(body); err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
}
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.ProxyStatusResp {
psr := model.ProxyStatusResp{
Name: status.Name,
Type: status.Type,
Status: status.Phase,
Err: status.Err,
}
baseCfg := status.Cfg.GetBaseConfig()
if baseCfg.LocalPort != 0 {
psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
}
psr.Plugin = baseCfg.Plugin.Type
if status.Err == "" {
psr.RemoteAddr = status.RemoteAddr
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
}
}
if c.manager.IsStoreProxyEnabled(status.Name) {
psr.Source = model.SourceStore
}
return psr
}
func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) {
proxies, err := c.manager.ListStoreProxies()
if err != nil {
return nil, c.toHTTPError(err)
}
resp := model.ProxyListResp{Proxies: make([]model.ProxyDefinition, 0, len(proxies))}
for _, p := range proxies {
payload, err := model.ProxyDefinitionFromConfigurer(p)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
resp.Proxies = append(resp.Proxies, payload)
}
slices.SortFunc(resp.Proxies, func(a, b model.ProxyDefinition) int {
return cmp.Compare(a.Name, b.Name)
})
return resp, nil
}
func (c *Controller) GetStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
p, err := c.manager.GetStoreProxy(name)
if err != nil {
return nil, c.toHTTPError(err)
}
payload, err := model.ProxyDefinitionFromConfigurer(p)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return payload, nil
}
func (c *Controller) CreateStoreProxy(ctx *httppkg.Context) (any, error) {
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var payload model.ProxyDefinition
if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if err := payload.Validate("", false); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
cfg, err := payload.ToConfigurer()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
created, err := c.manager.CreateStoreProxy(cfg)
if err != nil {
return nil, c.toHTTPError(err)
}
resp, err := model.ProxyDefinitionFromConfigurer(created)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return resp, nil
}
func (c *Controller) UpdateStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var payload model.ProxyDefinition
if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if err := payload.Validate(name, true); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
cfg, err := payload.ToConfigurer()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
updated, err := c.manager.UpdateStoreProxy(name, cfg)
if err != nil {
return nil, c.toHTTPError(err)
}
resp, err := model.ProxyDefinitionFromConfigurer(updated)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return resp, nil
}
func (c *Controller) DeleteStoreProxy(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required")
}
if err := c.manager.DeleteStoreProxy(name); err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
}
func (c *Controller) ListStoreVisitors(ctx *httppkg.Context) (any, error) {
visitors, err := c.manager.ListStoreVisitors()
if err != nil {
return nil, c.toHTTPError(err)
}
resp := model.VisitorListResp{Visitors: make([]model.VisitorDefinition, 0, len(visitors))}
for _, v := range visitors {
payload, err := model.VisitorDefinitionFromConfigurer(v)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
resp.Visitors = append(resp.Visitors, payload)
}
slices.SortFunc(resp.Visitors, func(a, b model.VisitorDefinition) int {
return cmp.Compare(a.Name, b.Name)
})
return resp, nil
}
func (c *Controller) GetStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
v, err := c.manager.GetStoreVisitor(name)
if err != nil {
return nil, c.toHTTPError(err)
}
payload, err := model.VisitorDefinitionFromConfigurer(v)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return payload, nil
}
func (c *Controller) CreateStoreVisitor(ctx *httppkg.Context) (any, error) {
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var payload model.VisitorDefinition
if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if err := payload.Validate("", false); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
cfg, err := payload.ToConfigurer()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
created, err := c.manager.CreateStoreVisitor(cfg)
if err != nil {
return nil, c.toHTTPError(err)
}
resp, err := model.VisitorDefinitionFromConfigurer(created)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return resp, nil
}
func (c *Controller) UpdateStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
body, err := ctx.Body()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read body error: %v", err))
}
var payload model.VisitorDefinition
if err := jsonx.Unmarshal(body, &payload); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("parse JSON error: %v", err))
}
if err := payload.Validate(name, true); err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
cfg, err := payload.ToConfigurer()
if err != nil {
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
}
updated, err := c.manager.UpdateStoreVisitor(name, cfg)
if err != nil {
return nil, c.toHTTPError(err)
}
resp, err := model.VisitorDefinitionFromConfigurer(updated)
if err != nil {
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
}
return resp, nil
}
func (c *Controller) DeleteStoreVisitor(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
if name == "" {
return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required")
}
if err := c.manager.DeleteStoreVisitor(name); err != nil {
return nil, c.toHTTPError(err)
}
return nil, nil
}

View File

@@ -0,0 +1,531 @@
package http
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/client/http/model"
"github.com/fatedier/frp/client/proxy"
v1 "github.com/fatedier/frp/pkg/config/v1"
httppkg "github.com/fatedier/frp/pkg/util/http"
)
type fakeConfigManager struct {
reloadFromFileFn func(strict bool) error
readConfigFileFn func() (string, error)
writeConfigFileFn func(content []byte) error
getProxyStatusFn func() []*proxy.WorkingStatus
isStoreProxyEnabledFn func(name string) bool
storeEnabledFn func() bool
listStoreProxiesFn func() ([]v1.ProxyConfigurer, error)
getStoreProxyFn func(name string) (v1.ProxyConfigurer, error)
createStoreProxyFn func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
updateStoreProxyFn func(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error)
deleteStoreProxyFn func(name string) error
listStoreVisitorsFn func() ([]v1.VisitorConfigurer, error)
getStoreVisitorFn func(name string) (v1.VisitorConfigurer, error)
createStoreVisitFn func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
updateStoreVisitFn func(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error)
deleteStoreVisitFn func(name string) error
gracefulCloseFn func(d time.Duration)
}
func (m *fakeConfigManager) ReloadFromFile(strict bool) error {
if m.reloadFromFileFn != nil {
return m.reloadFromFileFn(strict)
}
return nil
}
func (m *fakeConfigManager) ReadConfigFile() (string, error) {
if m.readConfigFileFn != nil {
return m.readConfigFileFn()
}
return "", nil
}
func (m *fakeConfigManager) WriteConfigFile(content []byte) error {
if m.writeConfigFileFn != nil {
return m.writeConfigFileFn(content)
}
return nil
}
func (m *fakeConfigManager) GetProxyStatus() []*proxy.WorkingStatus {
if m.getProxyStatusFn != nil {
return m.getProxyStatusFn()
}
return nil
}
func (m *fakeConfigManager) IsStoreProxyEnabled(name string) bool {
if m.isStoreProxyEnabledFn != nil {
return m.isStoreProxyEnabledFn(name)
}
return false
}
func (m *fakeConfigManager) StoreEnabled() bool {
if m.storeEnabledFn != nil {
return m.storeEnabledFn()
}
return false
}
func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) {
if m.listStoreProxiesFn != nil {
return m.listStoreProxiesFn()
}
return nil, nil
}
func (m *fakeConfigManager) GetStoreProxy(name string) (v1.ProxyConfigurer, error) {
if m.getStoreProxyFn != nil {
return m.getStoreProxyFn(name)
}
return nil, nil
}
func (m *fakeConfigManager) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
if m.createStoreProxyFn != nil {
return m.createStoreProxyFn(cfg)
}
return cfg, nil
}
func (m *fakeConfigManager) UpdateStoreProxy(name string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
if m.updateStoreProxyFn != nil {
return m.updateStoreProxyFn(name, cfg)
}
return cfg, nil
}
func (m *fakeConfigManager) DeleteStoreProxy(name string) error {
if m.deleteStoreProxyFn != nil {
return m.deleteStoreProxyFn(name)
}
return nil
}
func (m *fakeConfigManager) ListStoreVisitors() ([]v1.VisitorConfigurer, error) {
if m.listStoreVisitorsFn != nil {
return m.listStoreVisitorsFn()
}
return nil, nil
}
func (m *fakeConfigManager) GetStoreVisitor(name string) (v1.VisitorConfigurer, error) {
if m.getStoreVisitorFn != nil {
return m.getStoreVisitorFn(name)
}
return nil, nil
}
func (m *fakeConfigManager) CreateStoreVisitor(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
if m.createStoreVisitFn != nil {
return m.createStoreVisitFn(cfg)
}
return cfg, nil
}
func (m *fakeConfigManager) UpdateStoreVisitor(name string, cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
if m.updateStoreVisitFn != nil {
return m.updateStoreVisitFn(name, cfg)
}
return cfg, nil
}
func (m *fakeConfigManager) DeleteStoreVisitor(name string) error {
if m.deleteStoreVisitFn != nil {
return m.deleteStoreVisitFn(name)
}
return nil
}
func (m *fakeConfigManager) GracefulClose(d time.Duration) {
if m.gracefulCloseFn != nil {
m.gracefulCloseFn(d)
}
}
func newRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
return &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: name,
Type: "tcp",
ProxyBackend: v1.ProxyBackend{
LocalPort: 10080,
},
},
}
}
func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
status := &proxy.WorkingStatus{
Name: "shared-proxy",
Type: "tcp",
Phase: proxy.ProxyPhaseRunning,
RemoteAddr: ":8080",
Cfg: newRawTCPProxyConfig("shared-proxy"),
}
controller := &Controller{
serverAddr: "127.0.0.1",
manager: &fakeConfigManager{
isStoreProxyEnabledFn: func(name string) bool {
return name == "shared-proxy"
},
},
}
resp := controller.buildProxyStatusResp(status)
if resp.Source != "store" {
t.Fatalf("unexpected source: %q", resp.Source)
}
if resp.RemoteAddr != "127.0.0.1:8080" {
t.Fatalf("unexpected remote addr: %q", resp.RemoteAddr)
}
}
func TestReloadErrorMapping(t *testing.T) {
tests := []struct {
name string
err error
expectedCode int
}{
{name: "invalid arg", err: fmtError(configmgmt.ErrInvalidArgument, "bad cfg"), expectedCode: http.StatusBadRequest},
{name: "apply fail", err: fmtError(configmgmt.ErrApplyConfig, "reload failed"), expectedCode: http.StatusInternalServerError},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
controller := &Controller{
manager: &fakeConfigManager{reloadFromFileFn: func(bool) error { return tc.err }},
}
ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/reload", nil))
_, err := controller.Reload(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, tc.expectedCode)
})
}
}
func TestStoreProxyErrorMapping(t *testing.T) {
tests := []struct {
name string
err error
expectedCode int
}{
{name: "not found", err: fmtError(configmgmt.ErrNotFound, "not found"), expectedCode: http.StatusNotFound},
{name: "conflict", err: fmtError(configmgmt.ErrConflict, "exists"), expectedCode: http.StatusConflict},
{name: "internal", err: errors.New("persist failed"), expectedCode: http.StatusInternalServerError},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body := []byte(`{"name":"shared-proxy","type":"tcp","tcp":{"localPort":10080}}`)
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
controller := &Controller{
manager: &fakeConfigManager{
updateStoreProxyFn: func(_ string, _ v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
return nil, tc.err
},
},
}
_, err := controller.UpdateStoreProxy(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, tc.expectedCode)
})
}
}
func TestStoreVisitorErrorMapping(t *testing.T) {
body := []byte(`{"name":"shared-visitor","type":"xtcp","xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret"}}`)
req := httptest.NewRequest(http.MethodDelete, "/api/store/visitors/shared-visitor", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "shared-visitor"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
controller := &Controller{
manager: &fakeConfigManager{
deleteStoreVisitFn: func(string) error {
return fmtError(configmgmt.ErrStoreDisabled, "disabled")
},
},
}
_, err := controller.DeleteStoreVisitor(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, http.StatusNotFound)
}
func TestCreateStoreProxyIgnoresUnknownFields(t *testing.T) {
var gotName string
controller := &Controller{
manager: &fakeConfigManager{
createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
gotName = cfg.GetBaseConfig().Name
return cfg, nil
},
},
}
body := []byte(`{"name":"raw-proxy","type":"tcp","unexpected":"value","tcp":{"localPort":10080,"unknownInBlock":"value"}}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
resp, err := controller.CreateStoreProxy(ctx)
if err != nil {
t.Fatalf("create store proxy: %v", err)
}
if gotName != "raw-proxy" {
t.Fatalf("unexpected proxy name: %q", gotName)
}
payload, ok := resp.(model.ProxyDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.Type != "tcp" || payload.TCP == nil {
t.Fatalf("unexpected payload: %#v", payload)
}
}
func TestCreateStoreVisitorIgnoresUnknownFields(t *testing.T) {
var gotName string
controller := &Controller{
manager: &fakeConfigManager{
createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
gotName = cfg.GetBaseConfig().Name
return cfg, nil
},
},
}
body := []byte(`{
"name":"raw-visitor","type":"xtcp","unexpected":"value",
"xtcp":{"serverName":"server","bindPort":10081,"secretKey":"secret","unknownInBlock":"value"}
}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
resp, err := controller.CreateStoreVisitor(ctx)
if err != nil {
t.Fatalf("create store visitor: %v", err)
}
if gotName != "raw-visitor" {
t.Fatalf("unexpected visitor name: %q", gotName)
}
payload, ok := resp.(model.VisitorDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.Type != "xtcp" || payload.XTCP == nil {
t.Fatalf("unexpected payload: %#v", payload)
}
}
func TestCreateStoreProxyPluginUnknownFieldsAreIgnored(t *testing.T) {
var gotPluginType string
controller := &Controller{
manager: &fakeConfigManager{
createStoreProxyFn: func(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
gotPluginType = cfg.GetBaseConfig().Plugin.Type
return cfg, nil
},
},
}
body := []byte(`{"name":"plugin-proxy","type":"tcp","tcp":{"plugin":{"type":"http2https","localAddr":"127.0.0.1:8080","unknownInPlugin":"value"}}}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/proxies", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
resp, err := controller.CreateStoreProxy(ctx)
if err != nil {
t.Fatalf("create store proxy: %v", err)
}
if gotPluginType != "http2https" {
t.Fatalf("unexpected plugin type: %q", gotPluginType)
}
payload, ok := resp.(model.ProxyDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.TCP == nil {
t.Fatalf("unexpected response payload: %#v", payload)
}
pluginType := payload.TCP.Plugin.Type
if pluginType != "http2https" {
t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
}
}
func TestCreateStoreVisitorPluginUnknownFieldsAreIgnored(t *testing.T) {
var gotPluginType string
controller := &Controller{
manager: &fakeConfigManager{
createStoreVisitFn: func(cfg v1.VisitorConfigurer) (v1.VisitorConfigurer, error) {
gotPluginType = cfg.GetBaseConfig().Plugin.Type
return cfg, nil
},
},
}
body := []byte(`{
"name":"plugin-visitor","type":"stcp",
"stcp":{"serverName":"server","bindPort":10081,"plugin":{"type":"virtual_net","destinationIP":"10.0.0.1","unknownInPlugin":"value"}}
}`)
req := httptest.NewRequest(http.MethodPost, "/api/store/visitors", bytes.NewReader(body))
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
resp, err := controller.CreateStoreVisitor(ctx)
if err != nil {
t.Fatalf("create store visitor: %v", err)
}
if gotPluginType != "virtual_net" {
t.Fatalf("unexpected plugin type: %q", gotPluginType)
}
payload, ok := resp.(model.VisitorDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.STCP == nil {
t.Fatalf("unexpected response payload: %#v", payload)
}
pluginType := payload.STCP.Plugin.Type
if pluginType != "virtual_net" {
t.Fatalf("unexpected plugin type in response payload: %q", pluginType)
}
}
func TestUpdateStoreProxyRejectsMismatchedTypeBlock(t *testing.T) {
controller := &Controller{manager: &fakeConfigManager{}}
body := []byte(`{"name":"p1","type":"tcp","udp":{"localPort":10080}}`)
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "p1"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, err := controller.UpdateStoreProxy(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, http.StatusBadRequest)
}
func TestUpdateStoreProxyRejectsNameMismatch(t *testing.T) {
controller := &Controller{manager: &fakeConfigManager{}}
body := []byte(`{"name":"p2","type":"tcp","tcp":{"localPort":10080}}`)
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/p1", bytes.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"name": "p1"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
_, err := controller.UpdateStoreProxy(ctx)
if err == nil {
t.Fatal("expected error")
}
assertHTTPCode(t, err, http.StatusBadRequest)
}
func TestListStoreProxiesReturnsSortedPayload(t *testing.T) {
controller := &Controller{
manager: &fakeConfigManager{
listStoreProxiesFn: func() ([]v1.ProxyConfigurer, error) {
b := newRawTCPProxyConfig("b")
a := newRawTCPProxyConfig("a")
return []v1.ProxyConfigurer{b, a}, nil
},
},
}
ctx := httppkg.NewContext(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/store/proxies", nil))
resp, err := controller.ListStoreProxies(ctx)
if err != nil {
t.Fatalf("list store proxies: %v", err)
}
out, ok := resp.(model.ProxyListResp)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if len(out.Proxies) != 2 {
t.Fatalf("unexpected proxy count: %d", len(out.Proxies))
}
if out.Proxies[0].Name != "a" || out.Proxies[1].Name != "b" {
t.Fatalf("proxies are not sorted by name: %#v", out.Proxies)
}
}
func fmtError(sentinel error, msg string) error {
return fmt.Errorf("%w: %s", sentinel, msg)
}
func assertHTTPCode(t *testing.T, err error, expected int) {
t.Helper()
var httpErr *httppkg.Error
if !errors.As(err, &httpErr) {
t.Fatalf("unexpected error type: %T", err)
}
if httpErr.Code != expected {
t.Fatalf("unexpected status code: got %d, want %d", httpErr.Code, expected)
}
}
func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) {
controller := &Controller{
manager: &fakeConfigManager{
updateStoreProxyFn: func(_ string, cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) {
return cfg, nil
},
},
}
body := map[string]any{
"name": "shared-proxy",
"type": "tcp",
"tcp": map[string]any{
"localPort": 10080,
"remotePort": 7000,
},
}
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/store/proxies/shared-proxy", bytes.NewReader(data))
req = mux.SetURLVars(req, map[string]string{"name": "shared-proxy"})
ctx := httppkg.NewContext(httptest.NewRecorder(), req)
resp, err := controller.UpdateStoreProxy(ctx)
if err != nil {
t.Fatalf("update store proxy: %v", err)
}
payload, ok := resp.(model.ProxyDefinition)
if !ok {
t.Fatalf("unexpected response type: %T", resp)
}
if payload.TCP == nil || payload.TCP.RemotePort != 7000 {
t.Fatalf("unexpected response payload: %#v", payload)
}
}

View File

@@ -0,0 +1,148 @@
package model
import (
"fmt"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type ProxyDefinition struct {
Name string `json:"name"`
Type string `json:"type"`
TCP *v1.TCPProxyConfig `json:"tcp,omitempty"`
UDP *v1.UDPProxyConfig `json:"udp,omitempty"`
HTTP *v1.HTTPProxyConfig `json:"http,omitempty"`
HTTPS *v1.HTTPSProxyConfig `json:"https,omitempty"`
TCPMux *v1.TCPMuxProxyConfig `json:"tcpmux,omitempty"`
STCP *v1.STCPProxyConfig `json:"stcp,omitempty"`
SUDP *v1.SUDPProxyConfig `json:"sudp,omitempty"`
XTCP *v1.XTCPProxyConfig `json:"xtcp,omitempty"`
}
func (p *ProxyDefinition) Validate(pathName string, isUpdate bool) error {
if strings.TrimSpace(p.Name) == "" {
return fmt.Errorf("proxy name is required")
}
if !IsProxyType(p.Type) {
return fmt.Errorf("invalid proxy type: %s", p.Type)
}
if isUpdate && pathName != "" && pathName != p.Name {
return fmt.Errorf("proxy name in URL must match name in body")
}
_, blockType, blockCount := p.activeBlock()
if blockCount != 1 {
return fmt.Errorf("exactly one proxy type block is required")
}
if blockType != p.Type {
return fmt.Errorf("proxy type block %q does not match type %q", blockType, p.Type)
}
return nil
}
func (p *ProxyDefinition) ToConfigurer() (v1.ProxyConfigurer, error) {
block, _, _ := p.activeBlock()
if block == nil {
return nil, fmt.Errorf("exactly one proxy type block is required")
}
cfg := block
cfg.GetBaseConfig().Name = p.Name
cfg.GetBaseConfig().Type = p.Type
return cfg, nil
}
func ProxyDefinitionFromConfigurer(cfg v1.ProxyConfigurer) (ProxyDefinition, error) {
if cfg == nil {
return ProxyDefinition{}, fmt.Errorf("proxy config is nil")
}
base := cfg.GetBaseConfig()
payload := ProxyDefinition{
Name: base.Name,
Type: base.Type,
}
switch c := cfg.(type) {
case *v1.TCPProxyConfig:
payload.TCP = c
case *v1.UDPProxyConfig:
payload.UDP = c
case *v1.HTTPProxyConfig:
payload.HTTP = c
case *v1.HTTPSProxyConfig:
payload.HTTPS = c
case *v1.TCPMuxProxyConfig:
payload.TCPMux = c
case *v1.STCPProxyConfig:
payload.STCP = c
case *v1.SUDPProxyConfig:
payload.SUDP = c
case *v1.XTCPProxyConfig:
payload.XTCP = c
default:
return ProxyDefinition{}, fmt.Errorf("unsupported proxy configurer type %T", cfg)
}
return payload, nil
}
func (p *ProxyDefinition) activeBlock() (v1.ProxyConfigurer, string, int) {
count := 0
var block v1.ProxyConfigurer
var blockType string
if p.TCP != nil {
count++
block = p.TCP
blockType = "tcp"
}
if p.UDP != nil {
count++
block = p.UDP
blockType = "udp"
}
if p.HTTP != nil {
count++
block = p.HTTP
blockType = "http"
}
if p.HTTPS != nil {
count++
block = p.HTTPS
blockType = "https"
}
if p.TCPMux != nil {
count++
block = p.TCPMux
blockType = "tcpmux"
}
if p.STCP != nil {
count++
block = p.STCP
blockType = "stcp"
}
if p.SUDP != nil {
count++
block = p.SUDP
blockType = "sudp"
}
if p.XTCP != nil {
count++
block = p.XTCP
blockType = "xtcp"
}
return block, blockType, count
}
func IsProxyType(typ string) bool {
switch typ {
case "tcp", "udp", "http", "https", "tcpmux", "stcp", "sudp", "xtcp":
return true
default:
return false
}
}

View File

@@ -0,0 +1,42 @@
// 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 model
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"
}
// ProxyListResp is the response for GET /api/store/proxies
type ProxyListResp struct {
Proxies []ProxyDefinition `json:"proxies"`
}
// VisitorListResp is the response for GET /api/store/visitors
type VisitorListResp struct {
Visitors []VisitorDefinition `json:"visitors"`
}

View File

@@ -0,0 +1,107 @@
package model
import (
"fmt"
"strings"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type VisitorDefinition struct {
Name string `json:"name"`
Type string `json:"type"`
STCP *v1.STCPVisitorConfig `json:"stcp,omitempty"`
SUDP *v1.SUDPVisitorConfig `json:"sudp,omitempty"`
XTCP *v1.XTCPVisitorConfig `json:"xtcp,omitempty"`
}
func (p *VisitorDefinition) Validate(pathName string, isUpdate bool) error {
if strings.TrimSpace(p.Name) == "" {
return fmt.Errorf("visitor name is required")
}
if !IsVisitorType(p.Type) {
return fmt.Errorf("invalid visitor type: %s", p.Type)
}
if isUpdate && pathName != "" && pathName != p.Name {
return fmt.Errorf("visitor name in URL must match name in body")
}
_, blockType, blockCount := p.activeBlock()
if blockCount != 1 {
return fmt.Errorf("exactly one visitor type block is required")
}
if blockType != p.Type {
return fmt.Errorf("visitor type block %q does not match type %q", blockType, p.Type)
}
return nil
}
func (p *VisitorDefinition) ToConfigurer() (v1.VisitorConfigurer, error) {
block, _, _ := p.activeBlock()
if block == nil {
return nil, fmt.Errorf("exactly one visitor type block is required")
}
cfg := block
cfg.GetBaseConfig().Name = p.Name
cfg.GetBaseConfig().Type = p.Type
return cfg, nil
}
func VisitorDefinitionFromConfigurer(cfg v1.VisitorConfigurer) (VisitorDefinition, error) {
if cfg == nil {
return VisitorDefinition{}, fmt.Errorf("visitor config is nil")
}
base := cfg.GetBaseConfig()
payload := VisitorDefinition{
Name: base.Name,
Type: base.Type,
}
switch c := cfg.(type) {
case *v1.STCPVisitorConfig:
payload.STCP = c
case *v1.SUDPVisitorConfig:
payload.SUDP = c
case *v1.XTCPVisitorConfig:
payload.XTCP = c
default:
return VisitorDefinition{}, fmt.Errorf("unsupported visitor configurer type %T", cfg)
}
return payload, nil
}
func (p *VisitorDefinition) activeBlock() (v1.VisitorConfigurer, string, int) {
count := 0
var block v1.VisitorConfigurer
var blockType string
if p.STCP != nil {
count++
block = p.STCP
blockType = "stcp"
}
if p.SUDP != nil {
count++
block = p.SUDP
blockType = "sudp"
}
if p.XTCP != nil {
count++
block = p.XTCP
blockType = "xtcp"
}
return block, blockType, count
}
func IsVisitorType(typ string) bool {
switch typ {
case "stcp", "sudp", "xtcp":
return true
default:
return false
}
}

View File

@@ -57,6 +57,7 @@ func NewProxy(
ctx context.Context,
pxyConf v1.ProxyConfigurer,
clientCfg *v1.ClientCommonConfig,
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
) (pxy Proxy) {
@@ -69,6 +70,7 @@ func NewProxy(
baseProxy := BaseProxy{
baseCfg: pxyConf.GetBaseConfig(),
clientCfg: clientCfg,
encryptionKey: encryptionKey,
limiter: limiter,
msgTransporter: msgTransporter,
vnetController: vnetController,
@@ -86,6 +88,7 @@ func NewProxy(
type BaseProxy struct {
baseCfg *v1.ProxyBaseConfig
clientCfg *v1.ClientCommonConfig
encryptionKey []byte
msgTransporter transport.MessageTransporter
vnetController *vnet.Controller
limiter *rate.Limiter
@@ -129,7 +132,7 @@ func (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) {
return
}
}
pxy.HandleTCPWorkConnection(conn, m, []byte(pxy.clientCfg.Auth.Token))
pxy.HandleTCPWorkConnection(conn, m, pxy.encryptionKey)
}
// Common handler for tcp work connections.

View File

@@ -40,7 +40,8 @@ type Manager struct {
closed bool
mu sync.RWMutex
clientCfg *v1.ClientCommonConfig
encryptionKey []byte
clientCfg *v1.ClientCommonConfig
ctx context.Context
}
@@ -48,6 +49,7 @@ type Manager struct {
func NewManager(
ctx context.Context,
clientCfg *v1.ClientCommonConfig,
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
) *Manager {
@@ -56,6 +58,7 @@ func NewManager(
msgTransporter: msgTransporter,
vnetController: vnetController,
closed: false,
encryptionKey: encryptionKey,
clientCfg: clientCfg,
ctx: ctx,
}
@@ -115,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())
}
@@ -163,7 +166,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
for _, cfg := range proxyCfgs {
name := cfg.GetBaseConfig().Name
if _, ok := pm.proxies[name]; !ok {
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
if pm.inWorkConnCallback != nil {
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
}

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,12 +87,15 @@ type Wrapper struct {
xl *xlog.Logger
ctx context.Context
wireName string
}
func NewWrapper(
ctx context.Context,
cfg v1.ProxyConfigurer,
clientCfg *v1.ClientCommonConfig,
encryptionKey []byte,
eventHandler event.Handler,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
@@ -112,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 {
@@ -122,7 +127,7 @@ func NewWrapper(
xl.Tracef("enable health check monitor")
}
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, pw.msgTransporter, pw.vnetController)
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)
return pw
}
@@ -181,7 +186,7 @@ func (pw *Wrapper) Stop() {
func (pw *Wrapper) close() {
_ = pw.handler(&event.CloseProxyPayload{
CloseProxyMsg: &msg.CloseProxy{
ProxyName: pw.Name,
ProxyName: pw.wireName,
},
})
}
@@ -207,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

@@ -91,7 +91,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
})
}
if pxy.cfg.Transport.UseEncryption {
rwc, err = libio.WithEncryption(rwc, []byte(pxy.clientCfg.Auth.Token))
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
if err != nil {
conn.Close()
xl.Errorf("create encryption stream error: %v", err)

View File

@@ -102,7 +102,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
})
}
if pxy.cfg.Transport.UseEncryption {
rwc, err = libio.WithEncryption(rwc, []byte(pxy.clientCfg.Auth.Token))
rwc, err = libio.WithEncryption(rwc, pxy.encryptionKey)
if err != nil {
conn.Close()
xl.Errorf("create encryption stream error: %v", err)
@@ -129,7 +129,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
return
}
if errRet := errors.PanicToError(func() {
xl.Tracef("get udp package from workConn: %s", udpMsg.Content)
xl.Tracef("get udp package from workConn, len: %d", len(udpMsg.Content))
readCh <- &udpMsg
}); errRet != nil {
xl.Infof("reader goroutine for udp work connection closed: %v", errRet)
@@ -145,7 +145,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
for rawMsg := range sendCh {
switch m := rawMsg.(type) {
case *msg.UDPPacket:
xl.Tracef("send udp package to workConn: %s", m.Content)
xl.Tracef("send udp package to workConn, len: %d", len(m.Content))
case *msg.Ping:
xl.Tracef("send ping message to udp workConn")
}

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,8 +29,11 @@ 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"
httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
@@ -60,9 +63,13 @@ 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
// ConfigFilePath is the path to the configuration file used to initialize.
// If it is empty, it means that the configuration file is not used for initialization.
@@ -108,19 +115,33 @@ type Service struct {
// Uniq id got from frps, it will be attached to loginMsg.
runID string
// Sets authentication based on selected method
authSetter auth.Setter
// Auth runtime and encryption materials
auth *auth.ClientAuth
// web server for admin UI and apis
webServer *httppkg.Server
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
// The configuration file used to initialize this client, or an empty
// string if no configuration file was used.
@@ -150,23 +171,44 @@ func NewService(options ServiceOptions) (*Service, error) {
webServer = ws
}
authSetter, err := auth.NewAuthSetter(options.Common.Auth)
authRuntime, err := auth.BuildClientAuth(&options.Common.Auth)
if err != nil {
return nil, err
}
if options.ConfigSourceAggregator == nil {
return nil, fmt.Errorf("config source aggregator is required")
}
configSource := options.ConfigSourceAggregator.ConfigSource()
storeSource := options.ConfigSourceAggregator.StoreSource()
proxyCfgs, visitorCfgs, loadErr := options.ConfigSourceAggregator.Load()
if loadErr != nil {
return nil, fmt.Errorf("failed to load config from aggregator: %w", loadErr)
}
proxyCfgs, visitorCfgs = config.FilterClientConfigurers(options.Common, proxyCfgs, visitorCfgs)
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
s := &Service{
ctx: context.Background(),
authSetter: authSetter,
auth: authRuntime,
webServer: webServer,
common: options.Common,
reloadCommon: options.Common,
configFilePath: options.ConfigFilePath,
proxyCfgs: options.ProxyCfgs,
visitorCfgs: options.VisitorCfgs,
unsafeFeatures: options.UnsafeFeatures,
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)
}
@@ -275,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,
@@ -290,7 +336,7 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
}
// Add auth
if err = svr.authSetter.SetLogin(loginMsg); err != nil {
if err = svr.auth.Setter.SetLogin(loginMsg); err != nil {
return
}
@@ -344,7 +390,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
RunID: svr.runID,
Conn: conn,
ConnEncrypted: connEncrypted,
AuthSetter: svr.authSetter,
Auth: svr.auth,
Connector: connector,
VnetController: svr.vnetController,
}
@@ -393,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))
}
@@ -403,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 {
@@ -443,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

@@ -15,6 +15,7 @@
package visitor
import (
"fmt"
"io"
"net"
"strconv"
@@ -24,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"
)
@@ -81,19 +83,31 @@ func (sv *STCPVisitor) internalConnWorker() {
func (sv *STCPVisitor) handleConn(userConn net.Conn) {
xl := xlog.FromContextSafe(sv.ctx)
defer userConn.Close()
var tunnelErr error
defer func() {
// If there was an error and connection supports CloseWithError, use it
if tunnelErr != nil {
if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok {
_ = eConn.CloseWithError(tunnelErr)
return
}
}
userConn.Close()
}()
xl.Debugf("get a new stcp user connection")
visitorConn, err := sv.helper.ConnectServer()
if err != nil {
tunnelErr = err
return
}
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,
@@ -102,6 +116,7 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
err = msg.WriteMsg(visitorConn, newVisitorConnMsg)
if err != nil {
xl.Warnf("send newVisitorConnMsg to server error: %v", err)
tunnelErr = err
return
}
@@ -110,12 +125,14 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
err = msg.ReadMsgInto(visitorConn, &newVisitorConnRespMsg)
if err != nil {
xl.Warnf("get newVisitorConnRespMsg error: %v", err)
tunnelErr = err
return
}
_ = visitorConn.SetReadDeadline(time.Time{})
if newVisitorConnRespMsg.Error != "" {
xl.Warnf("start new visitor connection error: %s", newVisitorConnRespMsg.Error)
tunnelErr = fmt.Errorf("%s", newVisitorConnRespMsg.Error)
return
}
@@ -125,6 +142,7 @@ func (sv *STCPVisitor) handleConn(userConn net.Conn) {
remote, err = libio.WithEncryption(remote, []byte(sv.cfg.SecretKey))
if err != nil {
xl.Errorf("create encryption stream error: %v", err)
tunnelErr = err
return
}
}

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

View File

@@ -71,7 +71,7 @@ func NewVisitor(
Name: cfg.GetBaseConfig().Name,
Ctx: ctx,
VnetController: helper.VNetController(),
HandleConn: func(conn net.Conn) {
SendConnToVisitor: func(conn net.Conn) {
_ = baseVisitor.AcceptConn(conn)
},
},

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"
@@ -162,8 +163,16 @@ func (sv *XTCPVisitor) keepTunnelOpenWorker() {
func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
xl := xlog.FromContextSafe(sv.ctx)
isConnTransferred := false
var tunnelErr error
defer func() {
if !isConnTransferred {
// If there was an error and connection supports CloseWithError, use it
if tunnelErr != nil {
if eConn, ok := userConn.(interface{ CloseWithError(error) error }); ok {
_ = eConn.CloseWithError(tunnelErr)
return
}
}
userConn.Close()
}
}()
@@ -181,6 +190,8 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
tunnelConn, err := sv.openTunnel(ctx)
if err != nil {
xl.Errorf("open tunnel error: %v", err)
tunnelErr = err
// no fallback, just return
if sv.cfg.FallbackTo == "" {
return
@@ -200,6 +211,7 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
muxConnRWCloser, err = libio.WithEncryption(muxConnRWCloser, []byte(sv.cfg.SecretKey))
if err != nil {
xl.Errorf("create encryption stream error: %v", err)
tunnelErr = err
return
}
}
@@ -269,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
}
@@ -299,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,8 +22,10 @@ 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"
)
var proxyTypes = []v1.ProxyType{
@@ -77,18 +79,22 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
fmt.Println(err)
os.Exit(1)
}
if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil {
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
validator := validation.NewConfigValidator(unsafeFeatures)
if _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil {
fmt.Println(err)
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, "")
err := startService(clientCfg, []v1.ProxyConfigurer{proxyCfg}, nil, unsafeFeatures, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -106,18 +112,21 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
fmt.Println(err)
os.Exit(1)
}
if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil {
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
validator := validation.NewConfigValidator(unsafeFeatures)
if _, err := validator.ValidateClientCommonConfig(clientCfg); err != nil {
fmt.Println(err)
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}, "")
err := startService(clientCfg, nil, []v1.VisitorConfigurer{visitorCfg}, unsafeFeatures, "")
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -125,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

@@ -21,6 +21,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -29,9 +30,11 @@ 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/featuregate"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
)
@@ -41,6 +44,7 @@ var (
cfgDir string
showVersion bool
strictConfigMode bool
allowUnsafe []string
)
func init() {
@@ -48,6 +52,9 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
}
var rootCmd = &cobra.Command{
@@ -59,15 +66,17 @@ var rootCmd = &cobra.Command{
return nil
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
// Note that it's only designed for testing. It's not guaranteed to be stable.
if cfgDir != "" {
_ = runMultipleClients(cfgDir)
_ = runMultipleClients(cfgDir, unsafeFeatures)
return nil
}
// Do not show command usage here.
err := runClient(cfgFile)
err := runClient(cfgFile, unsafeFeatures)
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -76,7 +85,7 @@ var rootCmd = &cobra.Command{
},
}
func runMultipleClients(cfgDir string) error {
func runMultipleClients(cfgDir string, unsafeFeatures *security.UnsafeFeatures) error {
var wg sync.WaitGroup
err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
@@ -86,7 +95,7 @@ func runMultipleClients(cfgDir string) error {
time.Sleep(time.Millisecond)
go func() {
defer wg.Done()
err := runClient(path)
err := runClient(path, unsafeFeatures)
if err != nil {
fmt.Printf("frpc service error for config file [%s]\n", path)
}
@@ -111,56 +120,98 @@ func handleTermSignal(svr *client.Service) {
svr.GracefulClose(500 * time.Millisecond)
}
func runClient(cfgFilePath string) error {
cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode)
func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error {
// 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)
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)
}
if err != nil {
return err
}
return startService(cfg, proxyCfgs, visitorCfgs, 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,
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

@@ -22,6 +22,7 @@ import (
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
)
func init() {
@@ -42,7 +43,8 @@ var verifyCmd = &cobra.Command{
fmt.Println(err)
os.Exit(1)
}
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs)
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}

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

@@ -18,12 +18,14 @@ import (
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/fatedier/frp/pkg/config"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
"github.com/fatedier/frp/server"
@@ -33,6 +35,7 @@ var (
cfgFile string
showVersion bool
strictConfigMode bool
allowUnsafe []string
serverCfg v1.ServerConfig
)
@@ -41,6 +44,8 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file of frps")
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frps")
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause errors")
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ServerUnsafeFeatures, ", ")))
config.RegisterServerConfigFlags(rootCmd, &serverCfg)
}
@@ -77,7 +82,9 @@ var rootCmd = &cobra.Command{
svrCfg = &serverCfg
}
warning, err := validation.ValidateServerConfig(svrCfg)
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
validator := validation.NewConfigValidator(unsafeFeatures)
warning, err := validator.ValidateServerConfig(svrCfg)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/security"
)
func init() {
@@ -42,7 +43,9 @@ var verifyCmd = &cobra.Command{
os.Exit(1)
}
warning, err := validation.ValidateServerConfig(svrCfg)
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
validator := validation.NewConfigValidator(unsafeFeatures)
warning, err := validator.ValidateServerConfig(svrCfg)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}

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,8 +143,16 @@ 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.
# Set 'enabled = false' in a proxy configuration to disable it.
# If 'enabled' is not set or set to true, the proxy is enabled by default.
# The 'enabled' field provides more granular control and is recommended over 'start'.
# Specify udp packet size, unit is byte. If not set, the default value is 1500.
# This parameter should be same between client and server.
# It affects the udp and sudp proxy.
@@ -169,6 +179,8 @@ metadatas.var2 = "123"
# If global user is not empty, it will be changed to {user}.{proxy} such as 'your_name.ssh'
name = "ssh"
type = "tcp"
# Enable or disable this proxy. true or omit this field to enable, false to disable.
# enabled = true
localIP = "127.0.0.1"
localPort = 22
# Limit bandwidth for this proxy, unit is KB and MB
@@ -253,6 +265,8 @@ healthCheck.httpHeaders=[
[[proxies]]
name = "web02"
type = "https"
# Disable this proxy by setting enabled to false
# enabled = false
localIP = "127.0.0.1"
localPort = 8000
subdomain = "web02"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

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

19
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
@@ -16,7 +16,7 @@ require (
github.com/pion/stun/v2 v2.0.0
github.com/pires/go-proxyproto v0.7.0
github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.53.0
github.com/quic-go/quic-go v0.55.0
github.com/rodaine/table v1.2.0
github.com/samber/lo v1.47.0
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
@@ -26,10 +26,10 @@ require (
github.com/tidwall/gjson v1.17.1
github.com/vishvananda/netlink v1.3.0
github.com/xtaci/kcp-go/v5 v5.6.13
golang.org/x/crypto v0.37.0
golang.org/x/net v0.39.0
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.13.0
golang.org/x/sync v0.16.0
golang.org/x/time v0.5.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
gopkg.in/ini.v1 v1.67.0
@@ -67,11 +67,10 @@ require (
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

40
go.sum
View File

@@ -105,8 +105,8 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
@@ -156,24 +156,24 @@ github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -187,8 +187,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
@@ -197,8 +197,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -213,24 +213,24 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -241,8 +241,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=

View File

@@ -15,6 +15,7 @@
package auth
import (
"context"
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
@@ -27,14 +28,51 @@ type Setter interface {
SetNewWorkConn(*msg.NewWorkConn) error
}
type ClientAuth struct {
Setter Setter
key []byte
}
func (a *ClientAuth) EncryptionKey() []byte {
return a.key
}
// BuildClientAuth resolves any dynamic auth values and returns a prepared auth runtime.
// Caller must run validation before calling this function.
func BuildClientAuth(cfg *v1.AuthClientConfig) (*ClientAuth, error) {
if cfg == nil {
return nil, fmt.Errorf("auth config is nil")
}
resolved := *cfg
if resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil {
token, err := resolved.TokenSource.Resolve(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
resolved.Token = token
}
setter, err := NewAuthSetter(resolved)
if err != nil {
return nil, err
}
return &ClientAuth{
Setter: setter,
key: []byte(resolved.Token),
}, nil
}
func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) {
switch cfg.Method {
case v1.AuthMethodToken:
authProvider = NewTokenAuth(cfg.AdditionalScopes, cfg.Token)
case v1.AuthMethodOIDC:
authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
if err != nil {
return nil, err
if cfg.OIDC.TokenSource != nil {
authProvider = NewOidcTokenSourceAuthSetter(cfg.AdditionalScopes, cfg.OIDC.TokenSource)
} else {
authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
if err != nil {
return nil, err
}
}
default:
return nil, fmt.Errorf("unsupported auth method: %s", cfg.Method)
@@ -48,6 +86,35 @@ type Verifier interface {
VerifyNewWorkConn(*msg.NewWorkConn) error
}
type ServerAuth struct {
Verifier Verifier
key []byte
}
func (a *ServerAuth) EncryptionKey() []byte {
return a.key
}
// BuildServerAuth resolves any dynamic auth values and returns a prepared auth runtime.
// Caller must run validation before calling this function.
func BuildServerAuth(cfg *v1.AuthServerConfig) (*ServerAuth, error) {
if cfg == nil {
return nil, fmt.Errorf("auth config is nil")
}
resolved := *cfg
if resolved.Method == v1.AuthMethodToken && resolved.TokenSource != nil {
token, err := resolved.TokenSource.Resolve(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
resolved.Token = token
}
return &ServerAuth{
Verifier: NewAuthVerifier(resolved),
key: []byte(resolved.Token),
}, nil
}
func NewAuthVerifier(cfg v1.AuthServerConfig) (authVerifier Verifier) {
switch cfg.Method {
case v1.AuthMethodToken:

View File

@@ -152,6 +152,51 @@ func (auth *OidcAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (e
return err
}
type OidcTokenSourceAuthProvider struct {
additionalAuthScopes []v1.AuthScope
valueSource *v1.ValueSource
}
func NewOidcTokenSourceAuthSetter(additionalAuthScopes []v1.AuthScope, valueSource *v1.ValueSource) *OidcTokenSourceAuthProvider {
return &OidcTokenSourceAuthProvider{
additionalAuthScopes: additionalAuthScopes,
valueSource: valueSource,
}
}
func (auth *OidcTokenSourceAuthProvider) generateAccessToken() (accessToken string, err error) {
ctx := context.Background()
accessToken, err = auth.valueSource.Resolve(ctx)
if err != nil {
return "", fmt.Errorf("couldn't acquire OIDC token for login: %v", err)
}
return
}
func (auth *OidcTokenSourceAuthProvider) SetLogin(loginMsg *msg.Login) (err error) {
loginMsg.PrivilegeKey, err = auth.generateAccessToken()
return err
}
func (auth *OidcTokenSourceAuthProvider) SetPing(pingMsg *msg.Ping) (err error) {
if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) {
return nil
}
pingMsg.PrivilegeKey, err = auth.generateAccessToken()
return err
}
func (auth *OidcTokenSourceAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) {
if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) {
return nil
}
newWorkConnMsg.PrivilegeKey, err = auth.generateAccessToken()
return err
}
type TokenVerifier interface {
Verify(context.Context, string) (*oidc.IDToken, error)
}

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

@@ -17,6 +17,7 @@ package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -33,6 +34,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -108,7 +110,21 @@ func LoadConfigureFromFile(path string, c any, strict bool) error {
if err != nil {
return err
}
return LoadConfigure(content, c, strict)
return LoadConfigure(content, c, strict, detectFormatFromPath(path))
}
// detectFormatFromPath returns a format hint based on the file extension.
func detectFormatFromPath(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".toml":
return "toml"
case ".yaml", ".yml":
return "yaml"
case ".json":
return "json"
default:
return ""
}
}
// parseYAMLWithDotFieldsHandling parses YAML with dot-prefixed fields handling
@@ -129,48 +145,136 @@ func parseYAMLWithDotFieldsHandling(content []byte, target any) error {
}
// Convert to JSON and decode with strict validation
jsonBytes, err := json.Marshal(temp)
jsonBytes, err := jsonx.Marshal(temp)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
return decodeJSONContent(jsonBytes, target, true)
}
func decodeJSONContent(content []byte, target any, strict bool) error {
if clientCfg, ok := target.(*v1.ClientConfig); ok {
decoded, err := v1.DecodeClientConfigJSON(content, v1.DecodeOptions{
DisallowUnknownFields: strict,
})
if err != nil {
return err
}
*clientCfg = decoded
return nil
}
return jsonx.UnmarshalWithOptions(content, target, jsonx.DecodeOptions{
RejectUnknownMembers: strict,
})
}
// LoadConfigure loads configuration from bytes and unmarshal into c.
// Now it supports json, yaml and toml format.
func LoadConfigure(b []byte, c any, strict bool) error {
v1.DisallowUnknownFieldsMu.Lock()
defer v1.DisallowUnknownFieldsMu.Unlock()
v1.DisallowUnknownFields = strict
// An optional format hint (e.g. "toml", "yaml", "json") can be provided
// to enable better error messages with line number information.
func LoadConfigure(b []byte, c any, strict bool, formats ...string) error {
format := ""
if len(formats) > 0 {
format = formats[0]
}
originalBytes := b
parsedFromTOML := false
var tomlObj any
// Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML).
if err := toml.Unmarshal(b, &tomlObj); err == nil {
b, err = json.Marshal(&tomlObj)
tomlErr := toml.Unmarshal(b, &tomlObj)
if tomlErr == nil {
parsedFromTOML = true
var err error
b, err = jsonx.Marshal(&tomlObj)
if err != nil {
return err
}
} else if format == "toml" {
// File is known to be TOML but has syntax errors.
return formatTOMLError(tomlErr)
}
// If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly.
if yaml.IsJSONBuffer(b) {
decoder := json.NewDecoder(bytes.NewBuffer(b))
if strict {
decoder.DisallowUnknownFields()
if err := decodeJSONContent(b, c, strict); err != nil {
return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
}
return decoder.Decode(c)
return nil
}
// Handle YAML content
if strict {
// In strict mode, always use our custom handler to support YAML merge
return parseYAMLWithDotFieldsHandling(b, c)
if err := parseYAMLWithDotFieldsHandling(b, c); err != nil {
return enhanceDecodeError(err, originalBytes, !parsedFromTOML)
}
return nil
}
// Non-strict mode, parse normally
return yaml.Unmarshal(b, c)
}
// formatTOMLError extracts line/column information from TOML decode errors.
func formatTOMLError(err error) error {
var decErr *toml.DecodeError
if errors.As(err, &decErr) {
row, col := decErr.Position()
return fmt.Errorf("toml: line %d, column %d: %s", row, col, decErr.Error())
}
var strictErr *toml.StrictMissingError
if errors.As(err, &strictErr) {
return strictErr
}
return err
}
// enhanceDecodeError tries to add field path and line number information to JSON/YAML decode errors.
func enhanceDecodeError(err error, originalContent []byte, includeLine bool) error {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) && typeErr.Field != "" {
if includeLine {
line := findFieldLineInContent(originalContent, typeErr.Field)
if line > 0 {
return fmt.Errorf("line %d: field \"%s\": cannot unmarshal %s into %s", line, typeErr.Field, typeErr.Value, typeErr.Type)
}
}
return fmt.Errorf("field \"%s\": cannot unmarshal %s into %s", typeErr.Field, typeErr.Value, typeErr.Type)
}
return err
}
// findFieldLineInContent searches the original config content for a field name
// and returns the 1-indexed line number where it appears, or 0 if not found.
func findFieldLineInContent(content []byte, fieldPath string) int {
if fieldPath == "" {
return 0
}
// Use the last component of the field path (e.g. "proxies" from "proxies" or
// "protocol" from "transport.protocol").
parts := strings.Split(fieldPath, ".")
searchKey := parts[len(parts)-1]
lines := bytes.Split(content, []byte("\n"))
for i, line := range lines {
trimmed := bytes.TrimSpace(line)
// Match TOML key assignments like: key = ...
if bytes.HasPrefix(trimmed, []byte(searchKey)) {
rest := bytes.TrimSpace(trimmed[len(searchKey):])
if len(rest) > 0 && rest[0] == '=' {
return i + 1
}
}
// Match TOML table array headers like: [[proxies]]
if bytes.Contains(trimmed, []byte("[["+searchKey+"]]")) {
return i + 1
}
}
return 0
}
func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) {
m.ProxyType = util.EmptyOr(m.ProxyType, string(v1.ProxyTypeTCP))
@@ -180,7 +284,7 @@ func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.
}
configurer.UnmarshalFromMsg(m)
configurer.Complete("")
configurer.Complete()
if err := validation.ValidateProxyConfigurerForServer(configurer, serverCfg); err != nil {
return nil, err
@@ -219,60 +323,132 @@ func LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error)
return svrCfg, isLegacyFormat, nil
}
// ClientConfigLoadResult contains the result of loading a client configuration file.
type ClientConfigLoadResult struct {
// Common contains the common client configuration.
Common *v1.ClientCommonConfig
// Proxies contains proxy configurations from inline [[proxies]] and includeConfigFiles.
// These are NOT completed (user prefix not added).
Proxies []v1.ProxyConfigurer
// Visitors contains visitor configurations from inline [[visitors]] and includeConfigFiles.
// These are NOT completed.
Visitors []v1.VisitorConfigurer
// IsLegacyFormat indicates whether the config file is in legacy INI format.
IsLegacyFormat bool
}
// LoadClientConfigResult loads and parses a client configuration file.
// It returns the raw configuration without completing proxies/visitors.
// The caller should call Complete on the configs manually for legacy behavior.
func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult, error) {
result := &ClientConfigLoadResult{
Proxies: make([]v1.ProxyConfigurer, 0),
Visitors: make([]v1.VisitorConfigurer, 0),
}
if DetectLegacyINIFormatFromFile(path) {
legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)
if err != nil {
return nil, err
}
result.Common = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
for _, c := range legacyProxyCfgs {
result.Proxies = append(result.Proxies, legacy.Convert_ProxyConf_To_v1(c))
}
for _, c := range legacyVisitorCfgs {
result.Visitors = append(result.Visitors, legacy.Convert_VisitorConf_To_v1(c))
}
result.IsLegacyFormat = true
} else {
allCfg := v1.ClientConfig{}
if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {
return nil, err
}
result.Common = &allCfg.ClientCommonConfig
for _, c := range allCfg.Proxies {
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
}
for _, c := range allCfg.Visitors {
result.Visitors = append(result.Visitors, c.VisitorConfigurer)
}
}
// Load additional config from includes.
// legacy ini format already handle this in ParseClientConfig.
if len(result.Common.IncludeConfigFiles) > 0 && !result.IsLegacyFormat {
extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(result.Common.IncludeConfigFiles, result.IsLegacyFormat, strict)
if err != nil {
return nil, err
}
result.Proxies = append(result.Proxies, extProxyCfgs...)
result.Visitors = append(result.Visitors, extVisitorCfgs...)
}
// Complete the common config
if result.Common != nil {
if err := result.Common.Complete(); err != nil {
return nil, err
}
}
return result, nil
}
func LoadClientConfig(path string, strict bool) (
*v1.ClientCommonConfig,
[]v1.ProxyConfigurer,
[]v1.VisitorConfigurer,
bool, error,
) {
var (
cliCfg *v1.ClientCommonConfig
proxyCfgs = make([]v1.ProxyConfigurer, 0)
visitorCfgs = make([]v1.VisitorConfigurer, 0)
isLegacyFormat bool
)
if DetectLegacyINIFormatFromFile(path) {
legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path)
if err != nil {
return nil, nil, nil, true, err
}
cliCfg = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon)
for _, c := range legacyProxyCfgs {
proxyCfgs = append(proxyCfgs, legacy.Convert_ProxyConf_To_v1(c))
}
for _, c := range legacyVisitorCfgs {
visitorCfgs = append(visitorCfgs, legacy.Convert_VisitorConf_To_v1(c))
}
isLegacyFormat = true
} else {
allCfg := v1.ClientConfig{}
if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil {
return nil, nil, nil, false, err
}
cliCfg = &allCfg.ClientCommonConfig
for _, c := range allCfg.Proxies {
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
}
for _, c := range allCfg.Visitors {
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
}
result, err := LoadClientConfigResult(path, strict)
if err != nil {
return nil, nil, nil, result != nil && result.IsLegacyFormat, err
}
// Load additional config from includes.
// legacy ini format already handle this in ParseClientConfig.
if len(cliCfg.IncludeConfigFiles) > 0 && !isLegacyFormat {
extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict)
if err != nil {
return nil, nil, nil, isLegacyFormat, err
}
proxyCfgs = append(proxyCfgs, extProxyCfgs...)
visitorCfgs = append(visitorCfgs, extVisitorCfgs...)
proxyCfgs := result.Proxies
visitorCfgs := result.Visitors
proxyCfgs, visitorCfgs = FilterClientConfigurers(result.Common, proxyCfgs, visitorCfgs)
proxyCfgs = CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = CompleteVisitorConfigurers(visitorCfgs)
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
}
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
proxyCfgs := proxies
for _, c := range proxyCfgs {
c.Complete()
}
return proxyCfgs
}
func CompleteVisitorConfigurers(visitors []v1.VisitorConfigurer) []v1.VisitorConfigurer {
visitorCfgs := visitors
for _, c := range visitorCfgs {
c.Complete()
}
return visitorCfgs
}
func FilterClientConfigurers(
common *v1.ClientCommonConfig,
proxies []v1.ProxyConfigurer,
visitors []v1.VisitorConfigurer,
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
if common == nil {
common = &v1.ClientCommonConfig{}
}
// Filter by start
if len(cliCfg.Start) > 0 {
startSet := sets.New(cliCfg.Start...)
proxyCfgs := proxies
visitorCfgs := visitors
// Filter by start across merged configurers from all sources.
// For example, store entries are also filtered by this set.
if len(common.Start) > 0 {
startSet := sets.New(common.Start...)
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {
return startSet.Has(c.GetBaseConfig().Name)
})
@@ -281,18 +457,17 @@ func LoadClientConfig(path string, strict bool) (
})
}
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
// Filter by enabled field in each proxy
// nil or true means enabled, false means disabled
proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool {
enabled := c.GetBaseConfig().Enabled
return enabled == nil || *enabled
})
visitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool {
enabled := c.GetBaseConfig().Enabled
return enabled == nil || *enabled
})
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"
@@ -188,6 +189,31 @@ unixPath = "/tmp/uds.sock"
require.Error(err)
}
func TestLoadClientConfigStrictMode_UnknownPluginField(t *testing.T) {
require := require.New(t)
content := `
serverPort = 7000
[[proxies]]
name = "test"
type = "tcp"
localPort = 6000
[proxies.plugin]
type = "http2https"
localAddr = "127.0.0.1:8080"
unknownInPlugin = "value"
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false)
require.NoError(err)
err = LoadConfigure([]byte(content), &clientCfg, true)
require.ErrorContains(err, "unknownInPlugin")
}
// TestYAMLMergeInStrictMode tests that YAML merge functionality works
// even in strict mode by properly handling dot-prefixed fields
func TestYAMLMergeInStrictMode(t *testing.T) {
@@ -273,6 +299,169 @@ proxies:
require.Equal("stcp", clientCfg.Proxies[0].ProxyConfigurer.GetBaseConfig().Type)
}
func TestFilterClientConfigurers_PreserveRawNamesAndNoMutation(t *testing.T) {
require := require.New(t)
enabled := true
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy-raw"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
proxyCfg.Enabled = &enabled
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor-raw"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server-raw"
visitorCfg.FallbackTo = "fallback-raw"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
visitorCfg.Enabled = &enabled
common := &v1.ClientCommonConfig{
User: "alice",
}
proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
require.Len(proxies, 1)
require.Len(visitors, 1)
p := proxies[0].GetBaseConfig()
require.Equal("proxy-raw", p.Name)
require.Empty(p.LocalIP)
v := visitors[0].GetBaseConfig()
require.Equal("visitor-raw", v.Name)
require.Equal("server-raw", v.ServerName)
require.Empty(v.BindAddr)
xtcp := visitors[0].(*v1.XTCPVisitorConfig)
require.Equal("fallback-raw", xtcp.FallbackTo)
require.Empty(xtcp.Protocol)
}
func TestCompleteProxyConfigurers_PreserveRawNames(t *testing.T) {
require := require.New(t)
enabled := true
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy-raw"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
proxyCfg.Enabled = &enabled
proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg})
require.Len(proxies, 1)
p := proxies[0].GetBaseConfig()
require.Equal("proxy-raw", p.Name)
require.Equal("127.0.0.1", p.LocalIP)
}
func TestCompleteVisitorConfigurers_PreserveRawNames(t *testing.T) {
require := require.New(t)
enabled := true
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor-raw"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server-raw"
visitorCfg.FallbackTo = "fallback-raw"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
visitorCfg.Enabled = &enabled
visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg})
require.Len(visitors, 1)
v := visitors[0].GetBaseConfig()
require.Equal("visitor-raw", v.Name)
require.Equal("server-raw", v.ServerName)
require.Equal("127.0.0.1", v.BindAddr)
xtcp := visitors[0].(*v1.XTCPVisitorConfig)
require.Equal("fallback-raw", xtcp.FallbackTo)
require.Equal("quic", xtcp.Protocol)
}
func TestCompleteProxyConfigurers_Idempotent(t *testing.T) {
require := require.New(t)
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
proxies := CompleteProxyConfigurers([]v1.ProxyConfigurer{proxyCfg})
firstProxyJSON, err := json.Marshal(proxies[0])
require.NoError(err)
proxies = CompleteProxyConfigurers(proxies)
secondProxyJSON, err := json.Marshal(proxies[0])
require.NoError(err)
require.Equal(string(firstProxyJSON), string(secondProxyJSON))
}
func TestCompleteVisitorConfigurers_Idempotent(t *testing.T) {
require := require.New(t)
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
visitors := CompleteVisitorConfigurers([]v1.VisitorConfigurer{visitorCfg})
firstVisitorJSON, err := json.Marshal(visitors[0])
require.NoError(err)
visitors = CompleteVisitorConfigurers(visitors)
secondVisitorJSON, err := json.Marshal(visitors[0])
require.NoError(err)
require.Equal(string(firstVisitorJSON), string(secondVisitorJSON))
}
func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
require := require.New(t)
enabled := true
disabled := false
proxyKeep := &v1.TCPProxyConfig{}
proxyKeep.Name = "keep"
proxyKeep.Type = "tcp"
proxyKeep.LocalPort = 10080
proxyKeep.Enabled = &enabled
proxyDropByStart := &v1.TCPProxyConfig{}
proxyDropByStart.Name = "drop-by-start"
proxyDropByStart.Type = "tcp"
proxyDropByStart.LocalPort = 10081
proxyDropByStart.Enabled = &enabled
proxyDropByEnabled := &v1.TCPProxyConfig{}
proxyDropByEnabled.Name = "drop-by-enabled"
proxyDropByEnabled.Type = "tcp"
proxyDropByEnabled.LocalPort = 10082
proxyDropByEnabled.Enabled = &disabled
common := &v1.ClientCommonConfig{
Start: []string{"keep"},
}
proxies, visitors := FilterClientConfigurers(common, []v1.ProxyConfigurer{
proxyKeep,
proxyDropByStart,
proxyDropByEnabled,
}, nil)
require.Len(visitors, 0)
require.Len(proxies, 1)
require.Equal("keep", proxies[0].GetBaseConfig().Name)
}
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
func TestYAMLEdgeCases(t *testing.T) {
require := require.New(t)
@@ -306,3 +495,111 @@ serverPort: 7000
require.Equal("127.0.0.1", clientCfg.ServerAddr)
require.Equal(7000, clientCfg.ServerPort)
}
func TestTOMLSyntaxErrorWithPosition(t *testing.T) {
require := require.New(t)
// TOML with syntax error (unclosed table array header)
content := `serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]
name = "test"
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.Error(err)
require.Contains(err.Error(), "toml")
require.Contains(err.Error(), "line")
require.Contains(err.Error(), "column")
}
func TestTOMLTypeMismatchErrorWithFieldInfo(t *testing.T) {
require := require.New(t)
// TOML with wrong type: proxies should be a table array, not a string
content := `serverAddr = "127.0.0.1"
serverPort = 7000
proxies = "this should be a table array"
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.Error(err)
// The error should contain field info
errMsg := err.Error()
require.Contains(errMsg, "proxies")
require.NotContains(errMsg, "line")
}
func TestFindFieldLineInContent(t *testing.T) {
content := []byte(`serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "test"
type = "tcp"
remotePort = 6000
`)
tests := []struct {
fieldPath string
wantLine int
}{
{"serverAddr", 1},
{"serverPort", 2},
{"name", 5},
{"type", 6},
{"remotePort", 7},
{"nonexistent", 0},
}
for _, tt := range tests {
t.Run(tt.fieldPath, func(t *testing.T) {
got := findFieldLineInContent(content, tt.fieldPath)
require.Equal(t, tt.wantLine, got)
})
}
}
func TestFormatDetection(t *testing.T) {
tests := []struct {
path string
format string
}{
{"config.toml", "toml"},
{"config.TOML", "toml"},
{"config.yaml", "yaml"},
{"config.yml", "yaml"},
{"config.json", "json"},
{"config.ini", ""},
{"config", ""},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
require.Equal(t, tt.format, detectFormatFromPath(tt.path))
})
}
}
func TestValidTOMLStillWorks(t *testing.T) {
require := require.New(t)
// Valid TOML with format hint should work fine
content := `serverAddr = "127.0.0.1"
serverPort = 7000
[[proxies]]
name = "test"
type = "tcp"
remotePort = 6000
`
clientCfg := v1.ClientConfig{}
err := LoadConfigure([]byte(content), &clientCfg, false, "toml")
require.NoError(err)
require.Equal("127.0.0.1", clientCfg.ServerAddr)
require.Equal(7000, clientCfg.ServerPort)
require.Len(clientCfg.Proxies, 1)
}

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

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

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

View File

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

View File

@@ -15,8 +15,6 @@
package v1
import (
"context"
"fmt"
"os"
"github.com/samber/lo"
@@ -39,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".
@@ -77,6 +77,9 @@ type ClientCommonConfig struct {
// Include other config files for proxies.
IncludeConfigFiles []string `json:"includes,omitempty"`
// Store config enables the built-in store source (not configurable via sources list).
Store StoreConfig `json:"store,omitempty"`
}
func (c *ClientCommonConfig) Complete() error {
@@ -198,17 +201,6 @@ type AuthClientConfig struct {
func (c *AuthClientConfig) Complete() error {
c.Method = util.EmptyOr(c.Method, "token")
// Resolve tokenSource during configuration loading
if c.Method == AuthMethodToken && c.TokenSource != nil {
token, err := c.TokenSource.Resolve(context.Background())
if err != nil {
return fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
// Move the resolved token to the Token field and clear TokenSource
c.Token = token
c.TokenSource = nil
}
return nil
}
@@ -239,6 +231,10 @@ type AuthOIDCClientConfig struct {
// Supports http, https, socks5, and socks5h proxy protocols.
// If empty, no proxy is used for OIDC connections.
ProxyURL string `json:"proxyURL,omitempty"`
// TokenSource specifies a custom dynamic source for the authorization token.
// This is mutually exclusive with every other field of this structure.
TokenSource *ValueSource `json:"tokenSource,omitempty"`
}
type VirtualNetConfig struct {

View File

@@ -15,8 +15,6 @@
package v1
import (
"os"
"path/filepath"
"testing"
"github.com/samber/lo"
@@ -38,68 +36,9 @@ func TestClientConfigComplete(t *testing.T) {
}
func TestAuthClientConfig_Complete(t *testing.T) {
// Create a temporary file for testing
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test_token")
testContent := "client-token-value"
err := os.WriteFile(testFile, []byte(testContent), 0o600)
require.NoError(t, err)
tests := []struct {
name string
config AuthClientConfig
expectToken string
expectPanic bool
}{
{
name: "tokenSource resolved to token",
config: AuthClientConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: testFile,
},
},
},
expectToken: testContent,
expectPanic: false,
},
{
name: "direct token unchanged",
config: AuthClientConfig{
Method: AuthMethodToken,
Token: "direct-token",
},
expectToken: "direct-token",
expectPanic: false,
},
{
name: "invalid tokenSource should panic",
config: AuthClientConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: "/non/existent/file",
},
},
},
expectPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.expectPanic {
err := tt.config.Complete()
require.Error(t, err)
} else {
err := tt.config.Complete()
require.NoError(t, err)
require.Equal(t, tt.expectToken, tt.config.Token)
require.Nil(t, tt.config.TokenSource, "TokenSource should be cleared after resolution")
}
})
}
require := require.New(t)
cfg := &AuthClientConfig{}
err := cfg.Complete()
require.NoError(err)
require.EqualValues("token", cfg.Method)
}

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,23 +15,11 @@
package v1
import (
"sync"
"maps"
"github.com/fatedier/frp/pkg/util/util"
)
// TODO(fatedier): Due to the current implementation issue of the go json library, the UnmarshalJSON method
// of a custom struct cannot access the DisallowUnknownFields parameter of the parent decoder.
// Here, a global variable is temporarily used to control whether unknown fields are allowed.
// Once the v2 version is implemented by the community, we can switch to a standardized approach.
//
// https://github.com/golang/go/issues/41144
// https://github.com/golang/go/discussions/63397
var (
DisallowUnknownFields = false
DisallowUnknownFieldsMu sync.Mutex
)
type AuthScope string
const (
@@ -104,6 +92,14 @@ type NatTraversalConfig struct {
DisableAssistedAddrs bool `json:"disableAssistedAddrs,omitempty"`
}
func (c *NatTraversalConfig) Clone() *NatTraversalConfig {
if c == nil {
return nil
}
out := *c
return &out
}
type LogConfig struct {
// This is destination where frp should write the logs.
// If "console" is used, logs will be printed to stdout, otherwise,
@@ -138,6 +134,12 @@ type HeaderOperations struct {
Set map[string]string `json:"set,omitempty"`
}
func (o HeaderOperations) Clone() HeaderOperations {
return HeaderOperations{
Set: maps.Clone(o.Set),
}
}
type HTTPHeader struct {
Name string `json:"name"`
Value string `json:"value"`

195
pkg/config/v1/decode.go Normal file
View File

@@ -0,0 +1,195 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package v1
import (
"errors"
"fmt"
"reflect"
"github.com/fatedier/frp/pkg/util/jsonx"
)
type DecodeOptions struct {
DisallowUnknownFields bool
}
func decodeJSONWithOptions(b []byte, out any, options DecodeOptions) error {
return jsonx.UnmarshalWithOptions(b, out, jsonx.DecodeOptions{
RejectUnknownMembers: options.DisallowUnknownFields,
})
}
func isJSONNull(b []byte) bool {
return len(b) == 0 || string(b) == "null"
}
type typedEnvelope struct {
Type string `json:"type"`
Plugin jsonx.RawMessage `json:"plugin,omitempty"`
}
func DecodeProxyConfigurerJSON(b []byte, options DecodeOptions) (ProxyConfigurer, error) {
if isJSONNull(b) {
return nil, errors.New("type is required")
}
var env typedEnvelope
if err := jsonx.Unmarshal(b, &env); err != nil {
return nil, err
}
configurer := NewProxyConfigurerByType(ProxyType(env.Type))
if configurer == nil {
return nil, fmt.Errorf("unknown proxy type: %s", env.Type)
}
if err := decodeJSONWithOptions(b, configurer, options); err != nil {
return nil, fmt.Errorf("unmarshal ProxyConfig error: %v", err)
}
if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) {
plugin, err := DecodeClientPluginOptionsJSON(env.Plugin, options)
if err != nil {
return nil, fmt.Errorf("unmarshal proxy plugin error: %v", err)
}
configurer.GetBaseConfig().Plugin = plugin
}
return configurer, nil
}
func DecodeVisitorConfigurerJSON(b []byte, options DecodeOptions) (VisitorConfigurer, error) {
if isJSONNull(b) {
return nil, errors.New("type is required")
}
var env typedEnvelope
if err := jsonx.Unmarshal(b, &env); err != nil {
return nil, err
}
configurer := NewVisitorConfigurerByType(VisitorType(env.Type))
if configurer == nil {
return nil, fmt.Errorf("unknown visitor type: %s", env.Type)
}
if err := decodeJSONWithOptions(b, configurer, options); err != nil {
return nil, fmt.Errorf("unmarshal VisitorConfig error: %v", err)
}
if len(env.Plugin) > 0 && !isJSONNull(env.Plugin) {
plugin, err := DecodeVisitorPluginOptionsJSON(env.Plugin, options)
if err != nil {
return nil, fmt.Errorf("unmarshal visitor plugin error: %v", err)
}
configurer.GetBaseConfig().Plugin = plugin
}
return configurer, nil
}
func DecodeClientPluginOptionsJSON(b []byte, options DecodeOptions) (TypedClientPluginOptions, error) {
if isJSONNull(b) {
return TypedClientPluginOptions{}, nil
}
var env typedEnvelope
if err := jsonx.Unmarshal(b, &env); err != nil {
return TypedClientPluginOptions{}, err
}
if env.Type == "" {
return TypedClientPluginOptions{}, errors.New("plugin type is empty")
}
v, ok := clientPluginOptionsTypeMap[env.Type]
if !ok {
return TypedClientPluginOptions{}, fmt.Errorf("unknown plugin type: %s", env.Type)
}
optionsStruct := reflect.New(v).Interface().(ClientPluginOptions)
if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil {
return TypedClientPluginOptions{}, fmt.Errorf("unmarshal ClientPluginOptions error: %v", err)
}
return TypedClientPluginOptions{
Type: env.Type,
ClientPluginOptions: optionsStruct,
}, nil
}
func DecodeVisitorPluginOptionsJSON(b []byte, options DecodeOptions) (TypedVisitorPluginOptions, error) {
if isJSONNull(b) {
return TypedVisitorPluginOptions{}, nil
}
var env typedEnvelope
if err := jsonx.Unmarshal(b, &env); err != nil {
return TypedVisitorPluginOptions{}, err
}
if env.Type == "" {
return TypedVisitorPluginOptions{}, errors.New("visitor plugin type is empty")
}
v, ok := visitorPluginOptionsTypeMap[env.Type]
if !ok {
return TypedVisitorPluginOptions{}, fmt.Errorf("unknown visitor plugin type: %s", env.Type)
}
optionsStruct := reflect.New(v).Interface().(VisitorPluginOptions)
if err := decodeJSONWithOptions(b, optionsStruct, options); err != nil {
return TypedVisitorPluginOptions{}, fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err)
}
return TypedVisitorPluginOptions{
Type: env.Type,
VisitorPluginOptions: optionsStruct,
}, nil
}
func DecodeClientConfigJSON(b []byte, options DecodeOptions) (ClientConfig, error) {
type rawClientConfig struct {
ClientCommonConfig
Proxies []jsonx.RawMessage `json:"proxies,omitempty"`
Visitors []jsonx.RawMessage `json:"visitors,omitempty"`
}
raw := rawClientConfig{}
if err := decodeJSONWithOptions(b, &raw, options); err != nil {
return ClientConfig{}, err
}
cfg := ClientConfig{
ClientCommonConfig: raw.ClientCommonConfig,
Proxies: make([]TypedProxyConfig, 0, len(raw.Proxies)),
Visitors: make([]TypedVisitorConfig, 0, len(raw.Visitors)),
}
for i, proxyData := range raw.Proxies {
proxyCfg, err := DecodeProxyConfigurerJSON(proxyData, options)
if err != nil {
return ClientConfig{}, fmt.Errorf("decode proxy at index %d: %w", i, err)
}
cfg.Proxies = append(cfg.Proxies, TypedProxyConfig{
Type: proxyCfg.GetBaseConfig().Type,
ProxyConfigurer: proxyCfg,
})
}
for i, visitorData := range raw.Visitors {
visitorCfg, err := DecodeVisitorConfigurerJSON(visitorData, options)
if err != nil {
return ClientConfig{}, fmt.Errorf("decode visitor at index %d: %w", i, err)
}
cfg.Visitors = append(cfg.Visitors, TypedVisitorConfig{
Type: visitorCfg.GetBaseConfig().Type,
VisitorConfigurer: visitorCfg,
})
}
return cfg, nil
}

View File

@@ -0,0 +1,86 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package v1
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDecodeProxyConfigurerJSON_StrictPluginUnknownFields(t *testing.T) {
require := require.New(t)
data := []byte(`{
"name":"p1",
"type":"tcp",
"localPort":10080,
"plugin":{
"type":"http2https",
"localAddr":"127.0.0.1:8080",
"unknownInPlugin":"value"
}
}`)
_, err := DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false})
require.NoError(err)
_, err = DecodeProxyConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true})
require.ErrorContains(err, "unknownInPlugin")
}
func TestDecodeVisitorConfigurerJSON_StrictPluginUnknownFields(t *testing.T) {
require := require.New(t)
data := []byte(`{
"name":"v1",
"type":"stcp",
"serverName":"server",
"bindPort":10081,
"plugin":{
"type":"virtual_net",
"destinationIP":"10.0.0.1",
"unknownInPlugin":"value"
}
}`)
_, err := DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: false})
require.NoError(err)
_, err = DecodeVisitorConfigurerJSON(data, DecodeOptions{DisallowUnknownFields: true})
require.ErrorContains(err, "unknownInPlugin")
}
func TestDecodeClientConfigJSON_StrictUnknownProxyField(t *testing.T) {
require := require.New(t)
data := []byte(`{
"serverPort":7000,
"proxies":[
{
"name":"p1",
"type":"tcp",
"localPort":10080,
"unknownField":"value"
}
]
}`)
_, err := DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: false})
require.NoError(err)
_, err = DecodeClientConfigJSON(data, DecodeOptions{DisallowUnknownFields: true})
require.ErrorContains(err, "unknownField")
}

View File

@@ -15,16 +15,13 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"maps"
"reflect"
"github.com/samber/lo"
"slices"
"github.com/fatedier/frp/pkg/config/types"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -102,14 +99,29 @@ 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"`
Name string `json:"name"`
Type string `json:"type"`
// Enabled controls whether this proxy is enabled. nil or true means enabled, false means disabled.
// This allows individual control over each proxy, complementing the global "start" field.
Enabled *bool `json:"enabled,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
Transport ProxyTransport `json:"transport,omitempty"`
// metadata info for each proxy
@@ -119,12 +131,27 @@ type ProxyBaseConfig struct {
ProxyBackend
}
func (c ProxyBaseConfig) Clone() ProxyBaseConfig {
out := c
out.Enabled = util.ClonePtr(c.Enabled)
out.Annotations = maps.Clone(c.Annotations)
out.Metadatas = maps.Clone(c.Metadatas)
out.HealthCheck = c.HealthCheck.Clone()
out.ProxyBackend = c.ProxyBackend.Clone()
return out
}
func (c ProxyBackend) Clone() ProxyBackend {
out := c
out.Plugin = c.Plugin.Clone()
return out
}
func (c *ProxyBaseConfig) GetBaseConfig() *ProxyBaseConfig {
return c
}
func (c *ProxyBaseConfig) Complete(namePrefix string) {
c.Name = lo.Ternary(namePrefix == "", "", namePrefix+".") + c.Name
func (c *ProxyBaseConfig) Complete() {
c.LocalIP = util.EmptyOr(c.LocalIP, "127.0.0.1")
c.Transport.BandwidthLimitMode = util.EmptyOr(c.Transport.BandwidthLimitMode, types.BandwidthLimitModeClient)
@@ -172,40 +199,24 @@ type TypedProxyConfig struct {
}
func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" {
return errors.New("type is required")
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
configurer, err := DecodeProxyConfigurerJSON(b, DecodeOptions{})
if err != nil {
return err
}
c.Type = typeStruct.Type
configurer := NewProxyConfigurerByType(ProxyType(typeStruct.Type))
if configurer == nil {
return fmt.Errorf("unknown proxy type: %s", typeStruct.Type)
}
decoder := json.NewDecoder(bytes.NewBuffer(b))
if DisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(configurer); err != nil {
return fmt.Errorf("unmarshal ProxyConfig error: %v", err)
}
c.Type = configurer.GetBaseConfig().Type
c.ProxyConfigurer = configurer
return nil
}
func (c *TypedProxyConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(c.ProxyConfigurer)
return jsonx.Marshal(c.ProxyConfigurer)
}
type ProxyConfigurer interface {
Complete(namePrefix string)
Complete()
GetBaseConfig() *ProxyBaseConfig
Clone() ProxyConfigurer
// MarshalToMsg marshals this config into a msg.NewProxy message. This
// function will be called on the frpc side.
MarshalToMsg(*msg.NewProxy)
@@ -268,6 +279,12 @@ func (c *TCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RemotePort = m.RemotePort
}
func (c *TCPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
return &out
}
var _ ProxyConfigurer = &UDPProxyConfig{}
type UDPProxyConfig struct {
@@ -288,6 +305,12 @@ func (c *UDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RemotePort = m.RemotePort
}
func (c *UDPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
return &out
}
var _ ProxyConfigurer = &HTTPProxyConfig{}
type HTTPProxyConfig struct {
@@ -331,6 +354,16 @@ func (c *HTTPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RouteByHTTPUser = m.RouteByHTTPUser
}
func (c *HTTPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.DomainConfig = c.DomainConfig.Clone()
out.Locations = slices.Clone(c.Locations)
out.RequestHeaders = c.RequestHeaders.Clone()
out.ResponseHeaders = c.ResponseHeaders.Clone()
return &out
}
var _ ProxyConfigurer = &HTTPSProxyConfig{}
type HTTPSProxyConfig struct {
@@ -352,6 +385,13 @@ func (c *HTTPSProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.SubDomain = m.SubDomain
}
func (c *HTTPSProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.DomainConfig = c.DomainConfig.Clone()
return &out
}
type TCPMultiplexerType string
const (
@@ -392,6 +432,13 @@ func (c *TCPMuxProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.RouteByHTTPUser = m.RouteByHTTPUser
}
func (c *TCPMuxProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.DomainConfig = c.DomainConfig.Clone()
return &out
}
var _ ProxyConfigurer = &STCPProxyConfig{}
type STCPProxyConfig struct {
@@ -415,6 +462,13 @@ func (c *STCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.AllowUsers = m.AllowUsers
}
func (c *STCPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.AllowUsers = slices.Clone(c.AllowUsers)
return &out
}
var _ ProxyConfigurer = &XTCPProxyConfig{}
type XTCPProxyConfig struct {
@@ -441,6 +495,14 @@ func (c *XTCPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.AllowUsers = m.AllowUsers
}
func (c *XTCPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.AllowUsers = slices.Clone(c.AllowUsers)
out.NatTraversal = c.NatTraversal.Clone()
return &out
}
var _ ProxyConfigurer = &SUDPProxyConfig{}
type SUDPProxyConfig struct {
@@ -463,3 +525,10 @@ func (c *SUDPProxyConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.Secretkey = m.Sk
c.AllowUsers = m.AllowUsers
}
func (c *SUDPProxyConfig) Clone() ProxyConfigurer {
out := *c
out.ProxyBaseConfig = c.ProxyBaseConfig.Clone()
out.AllowUsers = slices.Clone(c.AllowUsers)
return &out
}

View File

@@ -15,14 +15,11 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -54,6 +51,7 @@ var clientPluginOptionsTypeMap = map[string]reflect.Type{
type ClientPluginOptions interface {
Complete()
Clone() ClientPluginOptions
}
type TypedClientPluginOptions struct {
@@ -61,43 +59,25 @@ type TypedClientPluginOptions struct {
ClientPluginOptions
}
func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" {
return nil
func (c TypedClientPluginOptions) Clone() TypedClientPluginOptions {
out := c
if c.ClientPluginOptions != nil {
out.ClientPluginOptions = c.ClientPluginOptions.Clone()
}
return out
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error {
decoded, err := DecodeClientPluginOptionsJSON(b, DecodeOptions{})
if err != nil {
return err
}
c.Type = typeStruct.Type
if c.Type == "" {
return errors.New("plugin type is empty")
}
v, ok := clientPluginOptionsTypeMap[typeStruct.Type]
if !ok {
return fmt.Errorf("unknown plugin type: %s", typeStruct.Type)
}
options := reflect.New(v).Interface().(ClientPluginOptions)
decoder := json.NewDecoder(bytes.NewBuffer(b))
if DisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(options); err != nil {
return fmt.Errorf("unmarshal ClientPluginOptions error: %v", err)
}
c.ClientPluginOptions = options
*c = decoded
return nil
}
func (c *TypedClientPluginOptions) MarshalJSON() ([]byte, error) {
return json.Marshal(c.ClientPluginOptions)
return jsonx.Marshal(c.ClientPluginOptions)
}
type HTTP2HTTPSPluginOptions struct {
@@ -109,6 +89,15 @@ type HTTP2HTTPSPluginOptions struct {
func (o *HTTP2HTTPSPluginOptions) Complete() {}
func (o *HTTP2HTTPSPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
return &out
}
type HTTPProxyPluginOptions struct {
Type string `json:"type,omitempty"`
HTTPUser string `json:"httpUser,omitempty"`
@@ -117,6 +106,14 @@ type HTTPProxyPluginOptions struct {
func (o *HTTPProxyPluginOptions) Complete() {}
func (o *HTTPProxyPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type HTTPS2HTTPPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -131,6 +128,16 @@ func (o *HTTPS2HTTPPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
}
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
return &out
}
type HTTPS2HTTPSPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -145,6 +152,16 @@ func (o *HTTPS2HTTPSPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
}
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
out.EnableHTTP2 = util.ClonePtr(o.EnableHTTP2)
return &out
}
type HTTP2HTTPPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -154,6 +171,15 @@ type HTTP2HTTPPluginOptions struct {
func (o *HTTP2HTTPPluginOptions) Complete() {}
func (o *HTTP2HTTPPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
out.RequestHeaders = o.RequestHeaders.Clone()
return &out
}
type Socks5PluginOptions struct {
Type string `json:"type,omitempty"`
Username string `json:"username,omitempty"`
@@ -162,6 +188,14 @@ type Socks5PluginOptions struct {
func (o *Socks5PluginOptions) Complete() {}
func (o *Socks5PluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type StaticFilePluginOptions struct {
Type string `json:"type,omitempty"`
LocalPath string `json:"localPath,omitempty"`
@@ -172,6 +206,14 @@ type StaticFilePluginOptions struct {
func (o *StaticFilePluginOptions) Complete() {}
func (o *StaticFilePluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type UnixDomainSocketPluginOptions struct {
Type string `json:"type,omitempty"`
UnixPath string `json:"unixPath,omitempty"`
@@ -179,6 +221,14 @@ type UnixDomainSocketPluginOptions struct {
func (o *UnixDomainSocketPluginOptions) Complete() {}
func (o *UnixDomainSocketPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type TLS2RawPluginOptions struct {
Type string `json:"type,omitempty"`
LocalAddr string `json:"localAddr,omitempty"`
@@ -188,8 +238,24 @@ type TLS2RawPluginOptions struct {
func (o *TLS2RawPluginOptions) Complete() {}
func (o *TLS2RawPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}
type VirtualNetPluginOptions struct {
Type string `json:"type,omitempty"`
}
func (o *VirtualNetPluginOptions) Complete() {}
func (o *VirtualNetPluginOptions) Clone() ClientPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}

View File

@@ -15,9 +15,6 @@
package v1
import (
"context"
"fmt"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/types"
@@ -138,17 +135,6 @@ type AuthServerConfig struct {
func (c *AuthServerConfig) Complete() error {
c.Method = util.EmptyOr(c.Method, "token")
// Resolve tokenSource during configuration loading
if c.Method == AuthMethodToken && c.TokenSource != nil {
token, err := c.TokenSource.Resolve(context.Background())
if err != nil {
return fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
// Move the resolved token to the Token field and clear TokenSource
c.Token = token
c.TokenSource = nil
}
return nil
}

View File

@@ -15,8 +15,6 @@
package v1
import (
"os"
"path/filepath"
"testing"
"github.com/samber/lo"
@@ -35,68 +33,9 @@ func TestServerConfigComplete(t *testing.T) {
}
func TestAuthServerConfig_Complete(t *testing.T) {
// Create a temporary file for testing
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test_token")
testContent := "file-token-value"
err := os.WriteFile(testFile, []byte(testContent), 0o600)
require.NoError(t, err)
tests := []struct {
name string
config AuthServerConfig
expectToken string
expectPanic bool
}{
{
name: "tokenSource resolved to token",
config: AuthServerConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: testFile,
},
},
},
expectToken: testContent,
expectPanic: false,
},
{
name: "direct token unchanged",
config: AuthServerConfig{
Method: AuthMethodToken,
Token: "direct-token",
},
expectToken: "direct-token",
expectPanic: false,
},
{
name: "invalid tokenSource should panic",
config: AuthServerConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: "/non/existent/file",
},
},
},
expectPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.expectPanic {
err := tt.config.Complete()
require.Error(t, err)
} else {
err := tt.config.Complete()
require.NoError(t, err)
require.Equal(t, tt.expectToken, tt.config.Token)
require.Nil(t, tt.config.TokenSource, "TokenSource should be cleared after resolution")
}
})
}
require := require.New(t)
cfg := &AuthServerConfig{}
err := cfg.Complete()
require.NoError(err)
require.EqualValues("token", cfg.Method)
}

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

@@ -23,55 +23,109 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/featuregate"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
)
func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
var (
warnings Warning
errs error
)
// validate feature gates
if c.VirtualNet.Address != "" {
if !featuregate.Enabled(featuregate.VirtualNet) {
return warnings, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
}
validators := []func() (Warning, error){
func() (Warning, error) { return validateFeatureGates(c) },
func() (Warning, error) { return v.validateAuthConfig(&c.Auth) },
func() (Warning, error) { return nil, validateLogConfig(&c.Log) },
func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) },
func() (Warning, error) { return validateTransportConfig(&c.Transport) },
func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },
}
if !slices.Contains(SupportedAuthMethods, c.Auth.Method) {
for _, validator := range validators {
w, err := validator()
warnings = AppendError(warnings, w)
errs = AppendError(errs, err)
}
return warnings, errs
}
func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) {
if c.VirtualNet.Address != "" {
if !featuregate.Enabled(featuregate.VirtualNet) {
return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
}
}
return nil, nil
}
func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) {
var errs error
if !slices.Contains(SupportedAuthMethods, c.Method) {
errs = AppendError(errs, fmt.Errorf("invalid auth method, optional values are %v", SupportedAuthMethods))
}
if !lo.Every(SupportedAuthAdditionalScopes, c.Auth.AdditionalScopes) {
if !lo.Every(SupportedAuthAdditionalScopes, c.AdditionalScopes) {
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
}
// Validate token/tokenSource mutual exclusivity
if c.Auth.Token != "" && c.Auth.TokenSource != nil {
if c.Token != "" && c.TokenSource != nil {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
}
// Validate tokenSource if specified
if c.Auth.TokenSource != nil {
if err := c.Auth.TokenSource.Validate(); err != nil {
if c.TokenSource != nil {
if c.TokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := c.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}
}
if err := validateLogConfig(&c.Log); err != nil {
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
errs = AppendError(errs, err)
}
return nil, errs
}
if err := validateWebServerConfig(&c.WebServer); err != nil {
errs = AppendError(errs, err)
func (v *ConfigValidator) validateOIDCConfig(c *v1.AuthOIDCClientConfig) error {
if c.TokenSource == nil {
return nil
}
var errs error
// Validate oidc.tokenSource mutual exclusivity with other fields of oidc
if c.ClientID != "" || c.ClientSecret != "" || c.Audience != "" ||
c.Scope != "" || c.TokenEndpointURL != "" || len(c.AdditionalEndpointParams) > 0 ||
c.TrustedCaFile != "" || c.InsecureSkipVerify || c.ProxyURL != "" {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc"))
}
if c.TokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := c.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.oidc.tokenSource: %v", err))
}
return errs
}
if c.Transport.HeartbeatTimeout > 0 && c.Transport.HeartbeatInterval > 0 {
if c.Transport.HeartbeatTimeout < c.Transport.HeartbeatInterval {
func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
var (
warnings Warning
errs error
)
if c.HeartbeatTimeout > 0 && c.HeartbeatInterval > 0 {
if c.HeartbeatTimeout < c.HeartbeatInterval {
errs = AppendError(errs, fmt.Errorf("invalid transport.heartbeatTimeout, heartbeat timeout should not less than heartbeat interval"))
}
}
if !lo.FromPtr(c.Transport.TLS.Enable) {
if !lo.FromPtr(c.TLS.Enable) {
checkTLSConfig := func(name string, value string) Warning {
if value != "" {
return fmt.Errorf("%s is invalid when transport.tls.enable is false", name)
@@ -79,16 +133,20 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
return nil
}
warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.Transport.TLS.CertFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.Transport.TLS.KeyFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.Transport.TLS.TrustedCaFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.TLS.CertFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile))
}
if !slices.Contains(SupportedTransportProtocols, c.Transport.Protocol) {
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols))
}
return warnings, errs
}
for _, f := range c.IncludeConfigFiles {
func validateIncludeFiles(files []string) (Warning, error) {
var errs error
for _, f := range files {
absDir, err := filepath.Abs(filepath.Dir(f))
if err != nil {
errs = AppendError(errs, fmt.Errorf("include: parse directory of %s failed: %v", f, err))
@@ -98,13 +156,19 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
errs = AppendError(errs, fmt.Errorf("include: directory of %s not exist", f))
}
}
return warnings, errs
return nil, errs
}
func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) (Warning, error) {
func ValidateAllClientConfig(
c *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures *security.UnsafeFeatures,
) (Warning, error) {
validator := NewConfigValidator(unsafeFeatures)
var warnings Warning
if c != nil {
warning, err := ValidateClientCommonConfig(c)
warning, err := validator.ValidateClientCommonConfig(c)
warnings = AppendError(warnings, warning)
if err != nil {
return warnings, err

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,9 +21,10 @@ import (
"github.com/samber/lo"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
)
func ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
var (
warnings Warning
errs error
@@ -42,6 +43,11 @@ func ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
// Validate tokenSource if specified
if c.Auth.TokenSource != nil {
if c.Auth.TokenSource.Type == "exec" {
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
errs = AppendError(errs, err)
}
}
if err := c.Auth.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}

View File

@@ -0,0 +1,28 @@
package validation
import (
"fmt"
"github.com/fatedier/frp/pkg/policy/security"
)
// ConfigValidator holds the context dependencies for configuration validation.
type ConfigValidator struct {
unsafeFeatures *security.UnsafeFeatures
}
// NewConfigValidator creates a new ConfigValidator instance.
func NewConfigValidator(unsafeFeatures *security.UnsafeFeatures) *ConfigValidator {
return &ConfigValidator{
unsafeFeatures: unsafeFeatures,
}
}
// ValidateUnsafeFeature checks if a specific unsafe feature is enabled.
func (v *ConfigValidator) ValidateUnsafeFeature(feature string) error {
if !v.unsafeFeatures.IsEnabled(feature) {
return fmt.Errorf("unsafe feature %q is not enabled. "+
"To enable it, ensure it is allowed in the configuration or command line flags", feature)
}
return nil
}

View File

@@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
)
@@ -27,6 +28,7 @@ import (
type ValueSource struct {
Type string `json:"type"`
File *FileSource `json:"file,omitempty"`
Exec *ExecSource `json:"exec,omitempty"`
}
// FileSource specifies how to load a value from a file.
@@ -34,6 +36,18 @@ type FileSource struct {
Path string `json:"path"`
}
// ExecSource specifies how to get a value from another program launched as subprocess.
type ExecSource struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env []ExecEnvVar `json:"env,omitempty"`
}
type ExecEnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
// Validate validates the ValueSource configuration.
func (v *ValueSource) Validate() error {
if v == nil {
@@ -46,8 +60,13 @@ func (v *ValueSource) Validate() error {
return errors.New("file configuration is required when type is 'file'")
}
return v.File.Validate()
case "exec":
if v.Exec == nil {
return errors.New("exec configuration is required when type is 'exec'")
}
return v.Exec.Validate()
default:
return fmt.Errorf("unsupported value source type: %s (only 'file' is supported)", v.Type)
return fmt.Errorf("unsupported value source type: %s (only 'file' and 'exec' are supported)", v.Type)
}
}
@@ -60,6 +79,8 @@ func (v *ValueSource) Resolve(ctx context.Context) (string, error) {
switch v.Type {
case "file":
return v.File.Resolve(ctx)
case "exec":
return v.Exec.Resolve(ctx)
default:
return "", fmt.Errorf("unsupported value source type: %s", v.Type)
}
@@ -91,3 +112,47 @@ func (f *FileSource) Resolve(_ context.Context) (string, error) {
// Trim whitespace, which is important for file-based tokens
return strings.TrimSpace(string(content)), nil
}
// Validate validates the ExecSource configuration.
func (e *ExecSource) Validate() error {
if e == nil {
return errors.New("execSource cannot be nil")
}
if e.Command == "" {
return errors.New("exec command cannot be empty")
}
for _, env := range e.Env {
if env.Name == "" {
return errors.New("exec env name cannot be empty")
}
if strings.Contains(env.Name, "=") {
return errors.New("exec env name cannot contain '='")
}
}
return nil
}
// Resolve reads and returns the content captured from stdout of launched subprocess.
func (e *ExecSource) Resolve(ctx context.Context) (string, error) {
if err := e.Validate(); err != nil {
return "", err
}
cmd := exec.CommandContext(ctx, e.Command, e.Args...)
if len(e.Env) != 0 {
cmd.Env = os.Environ()
for _, env := range e.Env {
cmd.Env = append(cmd.Env, env.Name+"="+env.Value)
}
}
content, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to execute command %v: %v", e.Command, err)
}
// Trim whitespace, which is important for exec-based tokens
return strings.TrimSpace(string(content)), nil
}

View File

@@ -15,14 +15,9 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -32,8 +27,11 @@ type VisitorTransport struct {
}
type VisitorBaseConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Name string `json:"name"`
Type string `json:"type"`
// Enabled controls whether this visitor is enabled. nil or true means enabled, false means disabled.
// This allows individual control over each visitor, complementing the global "start" field.
Enabled *bool `json:"enabled,omitempty"`
Transport VisitorTransport `json:"transport,omitempty"`
SecretKey string `json:"secretKey,omitempty"`
// if the server user is not set, it defaults to the current user
@@ -49,31 +47,27 @@ type VisitorBaseConfig struct {
Plugin TypedVisitorPluginOptions `json:"plugin,omitempty"`
}
func (c VisitorBaseConfig) Clone() VisitorBaseConfig {
out := c
out.Enabled = util.ClonePtr(c.Enabled)
out.Plugin = c.Plugin.Clone()
return out
}
func (c *VisitorBaseConfig) GetBaseConfig() *VisitorBaseConfig {
return c
}
func (c *VisitorBaseConfig) Complete(g *ClientCommonConfig) {
func (c *VisitorBaseConfig) Complete() {
if c.BindAddr == "" {
c.BindAddr = "127.0.0.1"
}
namePrefix := ""
if g.User != "" {
namePrefix = g.User + "."
}
c.Name = namePrefix + c.Name
if c.ServerUser != "" {
c.ServerName = c.ServerUser + "." + c.ServerName
} else {
c.ServerName = namePrefix + c.ServerName
}
}
type VisitorConfigurer interface {
Complete(*ClientCommonConfig)
Complete()
GetBaseConfig() *VisitorBaseConfig
Clone() VisitorConfigurer
}
type VisitorType string
@@ -96,35 +90,18 @@ type TypedVisitorConfig struct {
}
func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" {
return errors.New("type is required")
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
configurer, err := DecodeVisitorConfigurerJSON(b, DecodeOptions{})
if err != nil {
return err
}
c.Type = typeStruct.Type
configurer := NewVisitorConfigurerByType(VisitorType(typeStruct.Type))
if configurer == nil {
return fmt.Errorf("unknown visitor type: %s", typeStruct.Type)
}
decoder := json.NewDecoder(bytes.NewBuffer(b))
if DisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(configurer); err != nil {
return fmt.Errorf("unmarshal VisitorConfig error: %v", err)
}
c.Type = configurer.GetBaseConfig().Type
c.VisitorConfigurer = configurer
return nil
}
func (c *TypedVisitorConfig) MarshalJSON() ([]byte, error) {
return json.Marshal(c.VisitorConfigurer)
return jsonx.Marshal(c.VisitorConfigurer)
}
func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer {
@@ -143,12 +120,24 @@ type STCPVisitorConfig struct {
VisitorBaseConfig
}
func (c *STCPVisitorConfig) Clone() VisitorConfigurer {
out := *c
out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
return &out
}
var _ VisitorConfigurer = &SUDPVisitorConfig{}
type SUDPVisitorConfig struct {
VisitorBaseConfig
}
func (c *SUDPVisitorConfig) Clone() VisitorConfigurer {
out := *c
out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
return &out
}
var _ VisitorConfigurer = &XTCPVisitorConfig{}
type XTCPVisitorConfig struct {
@@ -165,15 +154,18 @@ type XTCPVisitorConfig struct {
NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
}
func (c *XTCPVisitorConfig) Complete(g *ClientCommonConfig) {
c.VisitorBaseConfig.Complete(g)
func (c *XTCPVisitorConfig) Complete() {
c.VisitorBaseConfig.Complete()
c.Protocol = util.EmptyOr(c.Protocol, "quic")
c.MaxRetriesAnHour = util.EmptyOr(c.MaxRetriesAnHour, 8)
c.MinRetryInterval = util.EmptyOr(c.MinRetryInterval, 90)
c.FallbackTimeoutMs = util.EmptyOr(c.FallbackTimeoutMs, 1000)
if c.FallbackTo != "" {
c.FallbackTo = lo.Ternary(g.User == "", "", g.User+".") + c.FallbackTo
}
}
func (c *XTCPVisitorConfig) Clone() VisitorConfigurer {
out := *c
out.VisitorBaseConfig = c.VisitorBaseConfig.Clone()
out.NatTraversal = c.NatTraversal.Clone()
return &out
}

View File

@@ -15,11 +15,9 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"github.com/fatedier/frp/pkg/util/jsonx"
)
const (
@@ -32,6 +30,7 @@ var visitorPluginOptionsTypeMap = map[string]reflect.Type{
type VisitorPluginOptions interface {
Complete()
Clone() VisitorPluginOptions
}
type TypedVisitorPluginOptions struct {
@@ -39,43 +38,25 @@ type TypedVisitorPluginOptions struct {
VisitorPluginOptions
}
func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
if len(b) == 4 && string(b) == "null" {
return nil
func (c TypedVisitorPluginOptions) Clone() TypedVisitorPluginOptions {
out := c
if c.VisitorPluginOptions != nil {
out.VisitorPluginOptions = c.VisitorPluginOptions.Clone()
}
return out
}
typeStruct := struct {
Type string `json:"type"`
}{}
if err := json.Unmarshal(b, &typeStruct); err != nil {
func (c *TypedVisitorPluginOptions) UnmarshalJSON(b []byte) error {
decoded, err := DecodeVisitorPluginOptionsJSON(b, DecodeOptions{})
if err != nil {
return err
}
c.Type = typeStruct.Type
if c.Type == "" {
return errors.New("visitor plugin type is empty")
}
v, ok := visitorPluginOptionsTypeMap[typeStruct.Type]
if !ok {
return fmt.Errorf("unknown visitor plugin type: %s", typeStruct.Type)
}
options := reflect.New(v).Interface().(VisitorPluginOptions)
decoder := json.NewDecoder(bytes.NewBuffer(b))
if DisallowUnknownFields {
decoder.DisallowUnknownFields()
}
if err := decoder.Decode(options); err != nil {
return fmt.Errorf("unmarshal VisitorPluginOptions error: %v", err)
}
c.VisitorPluginOptions = options
*c = decoded
return nil
}
func (c *TypedVisitorPluginOptions) MarshalJSON() ([]byte, error) {
return json.Marshal(c.VisitorPluginOptions)
return jsonx.Marshal(c.VisitorPluginOptions)
}
type VirtualNetVisitorPluginOptions struct {
@@ -84,3 +65,11 @@ type VirtualNetVisitorPluginOptions struct {
}
func (o *VirtualNetVisitorPluginOptions) Complete() {}
func (o *VirtualNetVisitorPluginOptions) Clone() VisitorPluginOptions {
if o == nil {
return nil
}
out := *o
return &out
}

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

@@ -86,10 +86,6 @@ func (d *Dispatcher) Send(m Message) error {
}
}
func (d *Dispatcher) SendChannel() chan Message {
return d.sendCh
}
func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) {
d.msgHandlers[reflect.TypeOf(msg)] = handler
}

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

33
pkg/naming/names.go Normal file
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

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